@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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +496 -0
  3. package/dist/index.cjs +704 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +4 -0
  6. package/dist/index.d.ts +4 -0
  7. package/dist/index.js +688 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/react/index.cjs +326 -0
  10. package/dist/react/index.cjs.map +1 -0
  11. package/dist/react/index.d.cts +118 -0
  12. package/dist/react/index.d.ts +118 -0
  13. package/dist/react/index.js +306 -0
  14. package/dist/react/index.js.map +1 -0
  15. package/dist/server/index.cjs +704 -0
  16. package/dist/server/index.cjs.map +1 -0
  17. package/dist/server/index.d.cts +98 -0
  18. package/dist/server/index.d.ts +98 -0
  19. package/dist/server/index.js +688 -0
  20. package/dist/server/index.js.map +1 -0
  21. package/dist/server/nextjs.cjs +10 -0
  22. package/dist/server/nextjs.cjs.map +1 -0
  23. package/dist/server/nextjs.d.cts +17 -0
  24. package/dist/server/nextjs.d.ts +17 -0
  25. package/dist/server/nextjs.js +8 -0
  26. package/dist/server/nextjs.js.map +1 -0
  27. package/dist/stores/index.cjs +244 -0
  28. package/dist/stores/index.cjs.map +1 -0
  29. package/dist/stores/index.d.cts +58 -0
  30. package/dist/stores/index.d.ts +58 -0
  31. package/dist/stores/index.js +241 -0
  32. package/dist/stores/index.js.map +1 -0
  33. package/dist/transports/index.cjs +156 -0
  34. package/dist/transports/index.cjs.map +1 -0
  35. package/dist/transports/index.d.cts +48 -0
  36. package/dist/transports/index.d.ts +48 -0
  37. package/dist/transports/index.js +151 -0
  38. package/dist/transports/index.js.map +1 -0
  39. package/dist/types-BcoqxSLg.d.cts +42 -0
  40. package/dist/types-BcoqxSLg.d.ts +42 -0
  41. package/dist/ui/index.cjs +609 -0
  42. package/dist/ui/index.cjs.map +1 -0
  43. package/dist/ui/index.d.cts +127 -0
  44. package/dist/ui/index.d.ts +127 -0
  45. package/dist/ui/index.js +604 -0
  46. package/dist/ui/index.js.map +1 -0
  47. package/package.json +146 -0
@@ -0,0 +1,241 @@
1
+ import { Redis } from '@upstash/redis';
2
+
3
+ // src/stores/upstash-redis.ts
4
+ var SESSION_TTL_SECONDS = 604800;
5
+ var DEFAULT_CHAT_LIMITS = {
6
+ sessionTtlSeconds: SESSION_TTL_SECONDS};
7
+
8
+ // src/stores/upstash-redis.ts
9
+ function upstashRedisStore(opts) {
10
+ const redis = new Redis({ url: opts.url, token: opts.token });
11
+ const ttlSeconds = opts.ttlSeconds ?? DEFAULT_CHAT_LIMITS.sessionTtlSeconds;
12
+ const sessionKey = (sessionId) => `chat:sess:${sessionId}`;
13
+ const topicKey = (topicId) => `chat:topic:${topicId}`;
14
+ const messagesKey = (sessionId) => `chat:msgs:${sessionId}`;
15
+ async function writeSession(sessionId, session) {
16
+ await redis.set(sessionKey(sessionId), session, { ex: ttlSeconds });
17
+ await redis.set(topicKey(session.topicId), sessionId, {
18
+ ex: ttlSeconds
19
+ });
20
+ }
21
+ return {
22
+ async getSession(sessionId) {
23
+ const raw = await redis.get(sessionKey(sessionId));
24
+ return raw ?? null;
25
+ },
26
+ async createSession(sessionId, topicId, name) {
27
+ const session = {
28
+ topicId,
29
+ createdAt: Date.now(),
30
+ msgCount: 0,
31
+ name
32
+ };
33
+ await writeSession(sessionId, session);
34
+ return session;
35
+ },
36
+ /**
37
+ * Adopts a name onto an existing session. Returns null when the session has
38
+ * expired between the caller's read and this write.
39
+ */
40
+ async setSessionName(sessionId, name) {
41
+ const session = await this.getSession(sessionId);
42
+ if (!session) return null;
43
+ const next = { ...session, name };
44
+ await writeSession(sessionId, next);
45
+ return next;
46
+ },
47
+ /**
48
+ * Points a session at a replacement topic, dropping the reverse mapping for
49
+ * the old one so a recycled thread id can never resolve to a stale session.
50
+ */
51
+ async retopicSession(sessionId, session, topicId) {
52
+ if (session.topicId !== topicId) {
53
+ await redis.del(topicKey(session.topicId));
54
+ }
55
+ const next = { ...session, topicId };
56
+ await writeSession(sessionId, next);
57
+ return next;
58
+ },
59
+ async resolveTopic(topicId) {
60
+ return await redis.get(topicKey(topicId)) ?? null;
61
+ },
62
+ /**
63
+ * Appends a message and returns the new cursor (the list length).
64
+ *
65
+ * msgCount is incremented only for visitor messages, so the owner's replies
66
+ * never consume the visitor's per-session message budget.
67
+ */
68
+ async appendMessage(sessionId, message) {
69
+ const cursor = await redis.rpush(messagesKey(sessionId), message);
70
+ await redis.expire(messagesKey(sessionId), ttlSeconds);
71
+ const session = await this.getSession(sessionId);
72
+ if (session) {
73
+ await writeSession(sessionId, {
74
+ ...session,
75
+ msgCount: message.from === "visitor" ? session.msgCount + 1 : session.msgCount
76
+ });
77
+ }
78
+ return cursor;
79
+ },
80
+ async getMessagesSince(sessionId, since) {
81
+ const start = Number.isFinite(since) && since > 0 ? Math.floor(since) : 0;
82
+ const messages = await redis.lrange(
83
+ messagesKey(sessionId),
84
+ start,
85
+ -1
86
+ );
87
+ return { messages, cursor: start + messages.length };
88
+ },
89
+ /**
90
+ * INCR returns the post-increment value, so a fresh key returns 1 and EXPIRE is
91
+ * set only on that first hit. That keeps the window fixed rather than sliding
92
+ * forward on every request, which would make the limit unreachable.
93
+ */
94
+ async incrementCounter(key, ttlSeconds2) {
95
+ const count = await redis.incr(key);
96
+ if (count === 1) await redis.expire(key, ttlSeconds2);
97
+ return count;
98
+ }
99
+ };
100
+ }
101
+
102
+ // src/stores/memory.ts
103
+ var inMemoryStoreKeysSymbol = /* @__PURE__ */ Symbol.for("contact-chat.inMemoryStore.keys");
104
+ function inMemoryStore(opts) {
105
+ const ttlSeconds = opts?.ttlSeconds ?? DEFAULT_CHAT_LIMITS.sessionTtlSeconds;
106
+ const now = opts?.now ?? Date.now;
107
+ const sessions = /* @__PURE__ */ new Map();
108
+ const topics = /* @__PURE__ */ new Map();
109
+ const messages = /* @__PURE__ */ new Map();
110
+ const counters = /* @__PURE__ */ new Map();
111
+ const sessionKey = (sessionId) => `chat:sess:${sessionId}`;
112
+ const topicKey = (topicId) => `chat:topic:${topicId}`;
113
+ const messagesKey = (sessionId) => `chat:msgs:${sessionId}`;
114
+ function writeSession(sessionId, session) {
115
+ const expiresAt = now() + ttlSeconds * 1e3;
116
+ sessions.set(sessionKey(sessionId), {
117
+ session: structuredClone(session),
118
+ expiresAt
119
+ });
120
+ topics.set(topicKey(session.topicId), sessionId);
121
+ }
122
+ function getStoredSession(sessionId) {
123
+ const stored = sessions.get(sessionKey(sessionId));
124
+ if (!stored) return null;
125
+ if (stored.expiresAt <= now()) {
126
+ sessions.delete(sessionKey(sessionId));
127
+ return null;
128
+ }
129
+ return structuredClone(stored.session);
130
+ }
131
+ const store = {
132
+ async getSession(sessionId) {
133
+ return getStoredSession(sessionId);
134
+ },
135
+ async createSession(sessionId, topicId, name) {
136
+ const session = {
137
+ topicId,
138
+ createdAt: now(),
139
+ msgCount: 0,
140
+ name
141
+ };
142
+ writeSession(sessionId, session);
143
+ return structuredClone(session);
144
+ },
145
+ /**
146
+ * Adopts a name onto an existing session. Returns null when the session has
147
+ * expired between the caller's read and this write.
148
+ */
149
+ async setSessionName(sessionId, name) {
150
+ const session = getStoredSession(sessionId);
151
+ if (!session) return null;
152
+ const next = { ...session, name };
153
+ writeSession(sessionId, next);
154
+ return structuredClone(next);
155
+ },
156
+ /**
157
+ * Points a session at a replacement topic, dropping the reverse mapping for
158
+ * the old one so a recycled thread id can never resolve to a stale session.
159
+ */
160
+ async retopicSession(sessionId, session, topicId) {
161
+ if (session.topicId !== topicId) {
162
+ topics.delete(topicKey(session.topicId));
163
+ }
164
+ const next = { ...session, topicId };
165
+ writeSession(sessionId, next);
166
+ return structuredClone(next);
167
+ },
168
+ async resolveTopic(topicId) {
169
+ const key = topicKey(topicId);
170
+ const sessionId = topics.get(key);
171
+ if (!sessionId) return null;
172
+ const session = getStoredSession(sessionId);
173
+ if (!session) {
174
+ topics.delete(key);
175
+ return null;
176
+ }
177
+ return sessionId;
178
+ },
179
+ /**
180
+ * Appends a message and returns the new cursor (the list length).
181
+ *
182
+ * msgCount is incremented only for visitor messages, so the owner's replies
183
+ * never consume the visitor's per-session message budget.
184
+ */
185
+ async appendMessage(sessionId, message) {
186
+ const key = messagesKey(sessionId);
187
+ const expiresAt = now() + ttlSeconds * 1e3;
188
+ const stored = messages.get(key);
189
+ const existing = stored && stored.expiresAt > now() ? stored : null;
190
+ const list = existing ? [...existing.messages] : [];
191
+ list.push(structuredClone(message));
192
+ messages.set(key, { messages: list, expiresAt });
193
+ const session = getStoredSession(sessionId);
194
+ if (session) {
195
+ writeSession(sessionId, {
196
+ ...session,
197
+ msgCount: message.from === "visitor" ? session.msgCount + 1 : session.msgCount
198
+ });
199
+ }
200
+ return list.length;
201
+ },
202
+ async getMessagesSince(sessionId, since) {
203
+ const start = Number.isFinite(since) && since > 0 ? Math.floor(since) : 0;
204
+ const key = messagesKey(sessionId);
205
+ const stored = messages.get(key);
206
+ if (!stored || stored.expiresAt <= now()) {
207
+ return { messages: [], cursor: start };
208
+ }
209
+ const slice = stored.messages.slice(start);
210
+ return { messages: structuredClone(slice), cursor: start + slice.length };
211
+ },
212
+ /**
213
+ * INCR returns the post-increment value, so a fresh key returns 1 and EXPIRE is
214
+ * set only on that first hit. That keeps the window fixed rather than sliding
215
+ * forward on every request, which would make the limit unreachable.
216
+ */
217
+ async incrementCounter(key, ttlSeconds2) {
218
+ const existing = counters.get(key);
219
+ if (!existing || existing.expiresAt <= now()) {
220
+ counters.set(key, { count: 1, expiresAt: now() + ttlSeconds2 * 1e3 });
221
+ return 1;
222
+ }
223
+ existing.count++;
224
+ return existing.count;
225
+ },
226
+ /** Internal accessor for tests — not part of the ChatStore interface. */
227
+ [inMemoryStoreKeysSymbol]() {
228
+ return [
229
+ ...sessions.keys(),
230
+ ...topics.keys(),
231
+ ...messages.keys(),
232
+ ...counters.keys()
233
+ ];
234
+ }
235
+ };
236
+ return store;
237
+ }
238
+
239
+ export { inMemoryStore, upstashRedisStore };
240
+ //# sourceMappingURL=index.js.map
241
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/core/types.ts","../../src/stores/upstash-redis.ts","../../src/stores/memory.ts"],"names":["ttlSeconds"],"mappings":"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","file":"index.js","sourcesContent":["export type ChatAuthor = 'visitor' | 'owner';\n\nexport interface ChatMessage {\n id: string;\n from: ChatAuthor;\n text: string;\n ts: number;\n}\n\nexport interface ChatSession {\n topicId: number;\n createdAt: number;\n msgCount: number;\n // Optional because sessions created before the name gate shipped have none.\n name?: string;\n}\n\nexport interface SendRequestBody {\n sessionId: string;\n text: string;\n name?: string;\n honeypot?: string;\n}\n\nexport interface SendResponseBody {\n message: ChatMessage;\n cursor: number;\n}\n\nexport interface PollResponseBody {\n messages: ChatMessage[];\n cursor: number;\n}\n\nexport type ChatErrorCode =\n | 'validation'\n | 'rate_limited'\n | 'store_unavailable'\n | 'telegram_unavailable';\n\nexport interface ChatErrorBody {\n code: ChatErrorCode;\n error: string;\n}\n\n// @deprecated Use DEFAULT_CHAT_LIMITS.maxMessageLength instead.\nexport const MAX_MESSAGE_LENGTH = 1000;\n// @deprecated Use DEFAULT_CHAT_LIMITS.maxMessagesPerSession instead.\nexport const MAX_MESSAGES_PER_SESSION = 40;\n// @deprecated Use DEFAULT_CHAT_LIMITS.sessionTtlSeconds instead.\nexport const SESSION_TTL_SECONDS = 604800; // 7 days\n// @deprecated Use DEFAULT_CHAT_LIMITS.ratePerMinute instead.\nexport const RATE_PER_MINUTE = 5;\n// @deprecated Use DEFAULT_CHAT_LIMITS.ratePerDay instead.\nexport const RATE_PER_DAY = 30;\n\nexport interface ChatLimits {\n maxMessageLength: number;\n maxMessagesPerSession: number;\n sessionTtlSeconds: number;\n ratePerMinute: number;\n ratePerDay: number;\n}\n\nexport const DEFAULT_CHAT_LIMITS: ChatLimits = {\n maxMessageLength: MAX_MESSAGE_LENGTH,\n maxMessagesPerSession: MAX_MESSAGES_PER_SESSION,\n sessionTtlSeconds: SESSION_TTL_SECONDS,\n ratePerMinute: RATE_PER_MINUTE,\n ratePerDay: RATE_PER_DAY,\n};\n","import { Redis } from '@upstash/redis';\nimport type { ChatStore } from '../core/store-interface.js';\nimport type { ChatMessage, ChatSession } from '../core/types.js';\nimport { DEFAULT_CHAT_LIMITS } from '../core/types.js';\n\nexport interface UpstashRedisStoreOptions {\n url: string;\n token: string;\n /**\n * Sliding TTL applied to session, topic-map, and message-list writes.\n * Default: 604800 (7 days).\n */\n ttlSeconds?: number;\n}\n\n/**\n * Creates a ChatStore backed by Upstash Redis.\n *\n * The consumer resolves credentials. `Redis.fromEnv()` only reads\n * `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN`, but the Vercel\n * Upstash marketplace provisions `KV_REST_API_URL` / `KV_REST_API_TOKEN`.\n * Consumers should fall back between the two pairs (using `||`, not `??`,\n * since `vercel env pull` can write blank entries) and pass the resolved\n * `{ url, token }` here.\n */\nexport function upstashRedisStore(opts: UpstashRedisStoreOptions): ChatStore {\n const redis = new Redis({ url: opts.url, token: opts.token });\n const ttlSeconds = opts.ttlSeconds ?? DEFAULT_CHAT_LIMITS.sessionTtlSeconds;\n\n const sessionKey = (sessionId: string) => `chat:sess:${sessionId}`;\n const topicKey = (topicId: number) => `chat:topic:${topicId}`;\n const messagesKey = (sessionId: string) => `chat:msgs:${sessionId}`;\n\n /**\n * Writes the session and its reverse topic mapping, refreshing both TTLs.\n * Called on every session touch, which is what makes the 7-day window sliding.\n */\n async function writeSession(\n sessionId: string,\n session: ChatSession,\n ): Promise<void> {\n await redis.set(sessionKey(sessionId), session, { ex: ttlSeconds });\n await redis.set(topicKey(session.topicId), sessionId, {\n ex: ttlSeconds,\n });\n }\n\n return {\n async getSession(sessionId: string): Promise<ChatSession | null> {\n const raw = await redis.get<ChatSession>(sessionKey(sessionId));\n return raw ?? null;\n },\n\n async createSession(\n sessionId: string,\n topicId: number,\n name: string,\n ): Promise<ChatSession> {\n const session: ChatSession = {\n topicId,\n createdAt: Date.now(),\n msgCount: 0,\n name,\n };\n await writeSession(sessionId, session);\n return session;\n },\n\n /**\n * Adopts a name onto an existing session. Returns null when the session has\n * expired between the caller's read and this write.\n */\n async setSessionName(\n sessionId: string,\n name: string,\n ): Promise<ChatSession | null> {\n const session = await this.getSession(sessionId);\n if (!session) return null;\n const next: ChatSession = { ...session, name };\n await writeSession(sessionId, next);\n return next;\n },\n\n /**\n * Points a session at a replacement topic, dropping the reverse mapping for\n * the old one so a recycled thread id can never resolve to a stale session.\n */\n async retopicSession(\n sessionId: string,\n session: ChatSession,\n topicId: number,\n ): Promise<ChatSession> {\n if (session.topicId !== topicId) {\n await redis.del(topicKey(session.topicId));\n }\n const next: ChatSession = { ...session, topicId };\n await writeSession(sessionId, next);\n return next;\n },\n\n async resolveTopic(topicId: number): Promise<string | null> {\n return (await redis.get<string>(topicKey(topicId))) ?? null;\n },\n\n /**\n * Appends a message and returns the new cursor (the list length).\n *\n * msgCount is incremented only for visitor messages, so the owner's replies\n * never consume the visitor's per-session message budget.\n */\n async appendMessage(\n sessionId: string,\n message: ChatMessage,\n ): Promise<number> {\n const cursor = await redis.rpush(messagesKey(sessionId), message);\n await redis.expire(messagesKey(sessionId), ttlSeconds);\n\n const session = await this.getSession(sessionId);\n if (session) {\n await writeSession(sessionId, {\n ...session,\n msgCount:\n message.from === 'visitor' ? session.msgCount + 1 : session.msgCount,\n });\n }\n\n return cursor;\n },\n\n async getMessagesSince(\n sessionId: string,\n since: number,\n ): Promise<{ messages: ChatMessage[]; cursor: number }> {\n const start = Number.isFinite(since) && since > 0 ? Math.floor(since) : 0;\n const messages = await redis.lrange<ChatMessage>(\n messagesKey(sessionId),\n start,\n -1,\n );\n return { messages, cursor: start + messages.length };\n },\n\n /**\n * INCR returns the post-increment value, so a fresh key returns 1 and EXPIRE is\n * set only on that first hit. That keeps the window fixed rather than sliding\n * forward on every request, which would make the limit unreachable.\n */\n async incrementCounter(key: string, ttlSeconds: number): Promise<number> {\n const count = await redis.incr(key);\n if (count === 1) await redis.expire(key, ttlSeconds);\n return count;\n },\n };\n}\n","import type { ChatStore } from '../core/store-interface.js';\nimport type { ChatMessage, ChatSession } from '../core/types.js';\nimport { DEFAULT_CHAT_LIMITS } from '../core/types.js';\n\nexport interface InMemoryStoreOptions {\n /**\n * Sliding TTL applied to session, topic-map, and message-list writes.\n * Default: 604800 (7 days).\n */\n ttlSeconds?: number;\n /** Injectable clock for deterministic tests. Default: Date.now. */\n now?: () => number;\n}\n\ninterface StoredSession {\n session: ChatSession;\n expiresAt: number;\n}\n\ninterface StoredMessages {\n messages: ChatMessage[];\n expiresAt: number;\n}\n\ninterface StoredCounter {\n count: number;\n expiresAt: number;\n}\n\n/** Symbol for accessing internal key names in tests. */\nexport const inMemoryStoreKeysSymbol = Symbol.for('contact-chat.inMemoryStore.keys');\n\n/**\n * Creates a deterministic in-memory ChatStore, suitable for tests and the demo app.\n *\n * Each call returns a new, fully independent store instance with no shared state.\n */\nexport function inMemoryStore(opts?: InMemoryStoreOptions): ChatStore & {\n [inMemoryStoreKeysSymbol]: () => string[];\n} {\n const ttlSeconds = opts?.ttlSeconds ?? DEFAULT_CHAT_LIMITS.sessionTtlSeconds;\n const now = opts?.now ?? Date.now;\n\n // Internal maps using the same key namespace as UpstashRedisStore.\n const sessions = new Map<string, StoredSession>();\n const topics = new Map<string, string>(); // topicKey -> sessionId\n const messages = new Map<string, StoredMessages>();\n const counters = new Map<string, StoredCounter>();\n\n const sessionKey = (sessionId: string) => `chat:sess:${sessionId}`;\n const topicKey = (topicId: number) => `chat:topic:${topicId}`;\n const messagesKey = (sessionId: string) => `chat:msgs:${sessionId}`;\n\n /**\n * Writes the session and its reverse topic mapping, refreshing both TTLs.\n * Called on every session touch, which is what makes the 7-day window sliding.\n */\n function writeSession(sessionId: string, session: ChatSession): void {\n const expiresAt = now() + ttlSeconds * 1000;\n sessions.set(sessionKey(sessionId), {\n session: structuredClone(session),\n expiresAt,\n });\n topics.set(topicKey(session.topicId), sessionId);\n }\n\n function getStoredSession(sessionId: string): ChatSession | null {\n const stored = sessions.get(sessionKey(sessionId));\n if (!stored) return null;\n if (stored.expiresAt <= now()) {\n sessions.delete(sessionKey(sessionId));\n return null;\n }\n return structuredClone(stored.session);\n }\n\n const store = {\n async getSession(sessionId: string): Promise<ChatSession | null> {\n return getStoredSession(sessionId);\n },\n\n async createSession(\n sessionId: string,\n topicId: number,\n name: string,\n ): Promise<ChatSession> {\n const session: ChatSession = {\n topicId,\n createdAt: now(),\n msgCount: 0,\n name,\n };\n writeSession(sessionId, session);\n return structuredClone(session);\n },\n\n /**\n * Adopts a name onto an existing session. Returns null when the session has\n * expired between the caller's read and this write.\n */\n async setSessionName(\n sessionId: string,\n name: string,\n ): Promise<ChatSession | null> {\n const session = getStoredSession(sessionId);\n if (!session) return null;\n const next: ChatSession = { ...session, name };\n writeSession(sessionId, next);\n return structuredClone(next);\n },\n\n /**\n * Points a session at a replacement topic, dropping the reverse mapping for\n * the old one so a recycled thread id can never resolve to a stale session.\n */\n async retopicSession(\n sessionId: string,\n session: ChatSession,\n topicId: number,\n ): Promise<ChatSession> {\n if (session.topicId !== topicId) {\n topics.delete(topicKey(session.topicId));\n }\n const next: ChatSession = { ...session, topicId };\n writeSession(sessionId, next);\n return structuredClone(next);\n },\n\n async resolveTopic(topicId: number): Promise<string | null> {\n const key = topicKey(topicId);\n const sessionId = topics.get(key);\n if (!sessionId) return null;\n // Verify the session hasn't expired.\n const session = getStoredSession(sessionId);\n if (!session) {\n topics.delete(key);\n return null;\n }\n return sessionId;\n },\n\n /**\n * Appends a message and returns the new cursor (the list length).\n *\n * msgCount is incremented only for visitor messages, so the owner's replies\n * never consume the visitor's per-session message budget.\n */\n async appendMessage(\n sessionId: string,\n message: ChatMessage,\n ): Promise<number> {\n const key = messagesKey(sessionId);\n const expiresAt = now() + ttlSeconds * 1000;\n const stored = messages.get(key);\n const existing = stored && stored.expiresAt > now() ? stored : null;\n const list: ChatMessage[] = existing ? [...existing.messages] : [];\n list.push(structuredClone(message));\n messages.set(key, { messages: list, expiresAt });\n\n const session = getStoredSession(sessionId);\n if (session) {\n writeSession(sessionId, {\n ...session,\n msgCount:\n message.from === 'visitor' ? session.msgCount + 1 : session.msgCount,\n });\n }\n\n return list.length;\n },\n\n async getMessagesSince(\n sessionId: string,\n since: number,\n ): Promise<{ messages: ChatMessage[]; cursor: number }> {\n const start = Number.isFinite(since) && since > 0 ? Math.floor(since) : 0;\n const key = messagesKey(sessionId);\n const stored = messages.get(key);\n if (!stored || stored.expiresAt <= now()) {\n return { messages: [], cursor: start };\n }\n const slice = stored.messages.slice(start);\n return { messages: structuredClone(slice), cursor: start + slice.length };\n },\n\n /**\n * INCR returns the post-increment value, so a fresh key returns 1 and EXPIRE is\n * set only on that first hit. That keeps the window fixed rather than sliding\n * forward on every request, which would make the limit unreachable.\n */\n async incrementCounter(key: string, ttlSeconds: number): Promise<number> {\n const existing = counters.get(key);\n if (!existing || existing.expiresAt <= now()) {\n counters.set(key, { count: 1, expiresAt: now() + ttlSeconds * 1000 });\n return 1;\n }\n existing.count++;\n return existing.count;\n },\n\n /** Internal accessor for tests — not part of the ChatStore interface. */\n [inMemoryStoreKeysSymbol](): string[] {\n return [\n ...sessions.keys(),\n ...topics.keys(),\n ...messages.keys(),\n ...counters.keys(),\n ];\n },\n };\n\n return store;\n}\n"]}
@@ -0,0 +1,156 @@
1
+ 'use strict';
2
+
3
+ // src/transports/telegram.ts
4
+ var TelegramError = class extends Error {
5
+ constructor(message, description) {
6
+ super(message);
7
+ this.description = description;
8
+ }
9
+ description;
10
+ };
11
+ function isMissingThreadError(error) {
12
+ if (!(error instanceof TelegramError)) return false;
13
+ const detail = (error.description ?? error.message).toLowerCase();
14
+ return detail.includes("thread not found") || detail.includes("topic_deleted") || detail.includes("topic was deleted") || detail.includes("thread_not_found");
15
+ }
16
+ var API_BASE = "https://api.telegram.org";
17
+ function describeBrowser(userAgent) {
18
+ if (!userAgent) return "Unknown browser";
19
+ if (/edg\//i.test(userAgent)) return "Edge";
20
+ if (/opr\//i.test(userAgent)) return "Opera";
21
+ if (/chrome\//i.test(userAgent)) return "Chrome";
22
+ if (/firefox\//i.test(userAgent)) return "Firefox";
23
+ if (/safari\//i.test(userAgent)) return "Safari";
24
+ return "Unknown browser";
25
+ }
26
+ function buildTopicName(input) {
27
+ const parts = new Intl.DateTimeFormat("en-GB", {
28
+ timeZone: "Europe/Kyiv",
29
+ day: "2-digit",
30
+ month: "2-digit",
31
+ hour: "2-digit",
32
+ minute: "2-digit",
33
+ hour12: false
34
+ }).formatToParts(input.now);
35
+ const at = (type) => parts.find((part) => part.type === type)?.value ?? "00";
36
+ return [
37
+ input.name,
38
+ at("day"),
39
+ at("month"),
40
+ at("hour"),
41
+ at("minute"),
42
+ describeBrowser(input.userAgent)
43
+ ].join("-").slice(0, 128);
44
+ }
45
+ function telegramTransport(opts) {
46
+ const {
47
+ botToken,
48
+ chatId,
49
+ webhookSecret,
50
+ topicIconColor = 13338331,
51
+ buildTopicName: buildTopicNameFn = buildTopicName,
52
+ fetch: fetchFn = globalThis.fetch
53
+ } = opts;
54
+ if (!botToken) {
55
+ throw new Error("telegramTransport: botToken is required.");
56
+ }
57
+ if (!chatId) {
58
+ throw new Error("telegramTransport: chatId is required.");
59
+ }
60
+ if (!webhookSecret) {
61
+ throw new Error(
62
+ "telegramTransport: webhookSecret is required. Pass the same value you configured with Telegram via /setWebhook."
63
+ );
64
+ }
65
+ async function callApi(method, body) {
66
+ const response = await fetchFn(`${API_BASE}/bot${botToken}/${method}`, {
67
+ method: "POST",
68
+ headers: { "Content-Type": "application/json" },
69
+ body: JSON.stringify(body),
70
+ cache: "no-store"
71
+ });
72
+ const payload = await response.json();
73
+ if (!response.ok || !payload.ok) {
74
+ throw new TelegramError(
75
+ `${method} failed: ${payload.description ?? response.status}`,
76
+ payload.description
77
+ );
78
+ }
79
+ return payload.result;
80
+ }
81
+ return {
82
+ async createThread(input) {
83
+ const name = buildTopicNameFn(input);
84
+ const result = await callApi(
85
+ "createForumTopic",
86
+ {
87
+ chat_id: chatId,
88
+ name: name.slice(0, 128),
89
+ // Telegram snaps icon_color to a fixed six-colour palette and silently
90
+ // substitutes the nearest allowed value. 0xCB86DB is the purple one;
91
+ // an arbitrary brand hex such as 0x9333EA comes back as blue.
92
+ icon_color: topicIconColor
93
+ }
94
+ );
95
+ return result.message_thread_id;
96
+ },
97
+ async send(threadId, text) {
98
+ await callApi("sendMessage", {
99
+ chat_id: chatId,
100
+ message_thread_id: threadId,
101
+ text
102
+ });
103
+ },
104
+ /**
105
+ * Retitles an existing topic. Needed when a session created before the name
106
+ * gate shipped supplies a name for the first time — the topic already exists,
107
+ * so it has to be renamed rather than created.
108
+ */
109
+ async renameThread(threadId, input) {
110
+ const name = buildTopicNameFn(input);
111
+ await callApi("editForumTopic", {
112
+ chat_id: chatId,
113
+ message_thread_id: threadId,
114
+ name: name.slice(0, 128)
115
+ });
116
+ },
117
+ isMissingThreadError,
118
+ /**
119
+ * Narrows an inbound webhook update to an owner reply, or null.
120
+ *
121
+ * Every inbound filter lives here: not a message, sent by a bot, missing a
122
+ * message_thread_id (i.e. posted in the group's General area rather than a
123
+ * topic), or from an unexpected chat all return null.
124
+ */
125
+ parseInboundReply(update) {
126
+ if (typeof update !== "object" || update === null) return null;
127
+ const message = update.message;
128
+ if (typeof message !== "object" || message === null) return null;
129
+ const {
130
+ message_id: messageId,
131
+ message_thread_id: threadId,
132
+ text,
133
+ chat,
134
+ from
135
+ } = message;
136
+ if (from?.is_bot === true) return null;
137
+ if (typeof threadId !== "number") return null;
138
+ if (typeof messageId !== "number") return null;
139
+ if (typeof text !== "string" || text.trim().length === 0) return null;
140
+ if (!chatId || String(chat?.id) !== chatId) return null;
141
+ return { threadId, text, messageId };
142
+ },
143
+ verifyWebhook(request) {
144
+ const header = request.headers.get("x-telegram-bot-api-secret-token");
145
+ if (!header) return false;
146
+ return header === webhookSecret;
147
+ }
148
+ };
149
+ }
150
+
151
+ exports.TelegramError = TelegramError;
152
+ exports.buildTopicName = buildTopicName;
153
+ exports.describeBrowser = describeBrowser;
154
+ exports.telegramTransport = telegramTransport;
155
+ //# sourceMappingURL=index.cjs.map
156
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/transports/telegram.ts"],"names":[],"mappings":";;;AAEO,IAAM,aAAA,GAAN,cAA4B,KAAA,CAAM;AAAA,EACvC,WAAA,CACE,SAES,WAAA,EACT;AACA,IAAA,KAAA,CAAM,OAAO,CAAA;AAFJ,IAAA,IAAA,CAAA,WAAA,GAAA,WAAA;AAAA,EAGX;AAAA,EAHW,WAAA;AAIb;AAOO,SAAS,qBAAqB,KAAA,EAAyB;AAC5D,EAAA,IAAI,EAAE,KAAA,YAAiB,aAAA,CAAA,EAAgB,OAAO,KAAA;AAC9C,EAAA,MAAM,MAAA,GAAA,CAAU,KAAA,CAAM,WAAA,IAAe,KAAA,CAAM,SAAS,WAAA,EAAY;AAChE,EAAA,OACE,MAAA,CAAO,QAAA,CAAS,kBAAkB,CAAA,IAClC,OAAO,QAAA,CAAS,eAAe,CAAA,IAC/B,MAAA,CAAO,QAAA,CAAS,mBAAmB,CAAA,IACnC,MAAA,CAAO,SAAS,kBAAkB,CAAA;AAEtC;AAEA,IAAM,QAAA,GAAW,0BAAA;AAEV,SAAS,gBAAgB,SAAA,EAAkC;AAChE,EAAA,IAAI,CAAC,WAAW,OAAO,iBAAA;AACvB,EAAA,IAAI,QAAA,CAAS,IAAA,CAAK,SAAS,CAAA,EAAG,OAAO,MAAA;AACrC,EAAA,IAAI,QAAA,CAAS,IAAA,CAAK,SAAS,CAAA,EAAG,OAAO,OAAA;AACrC,EAAA,IAAI,WAAA,CAAY,IAAA,CAAK,SAAS,CAAA,EAAG,OAAO,QAAA;AACxC,EAAA,IAAI,YAAA,CAAa,IAAA,CAAK,SAAS,CAAA,EAAG,OAAO,SAAA;AACzC,EAAA,IAAI,WAAA,CAAY,IAAA,CAAK,SAAS,CAAA,EAAG,OAAO,QAAA;AACxC,EAAA,OAAO,iBAAA;AACT;AAQO,SAAS,eAAe,KAAA,EAA+B;AAC5D,EAAA,MAAM,KAAA,GAAQ,IAAI,IAAA,CAAK,cAAA,CAAe,OAAA,EAAS;AAAA,IAC7C,QAAA,EAAU,aAAA;AAAA,IACV,GAAA,EAAK,SAAA;AAAA,IACL,KAAA,EAAO,SAAA;AAAA,IACP,IAAA,EAAM,SAAA;AAAA,IACN,MAAA,EAAQ,SAAA;AAAA,IACR,MAAA,EAAQ;AAAA,GACT,CAAA,CAAE,aAAA,CAAc,KAAA,CAAM,GAAG,CAAA;AAE1B,EAAA,MAAM,EAAA,GAAK,CAAC,IAAA,KACV,KAAA,CAAM,IAAA,CAAK,CAAC,IAAA,KAAS,IAAA,CAAK,IAAA,KAAS,IAAI,CAAA,EAAG,KAAA,IAAS,IAAA;AAErD,EAAA,OAAO;AAAA,IACL,KAAA,CAAM,IAAA;AAAA,IACN,GAAG,KAAK,CAAA;AAAA,IACR,GAAG,OAAO,CAAA;AAAA,IACV,GAAG,MAAM,CAAA;AAAA,IACT,GAAG,QAAQ,CAAA;AAAA,IACX,eAAA,CAAgB,MAAM,SAAS;AAAA,IAE9B,IAAA,CAAK,GAAG,CAAA,CACR,KAAA,CAAM,GAAG,GAAG,CAAA;AACjB;AAeO,SAAS,kBAAkB,IAAA,EAA+C;AAC/E,EAAA,MAAM;AAAA,IACJ,QAAA;AAAA,IACA,MAAA;AAAA,IACA,aAAA;AAAA,IACA,cAAA,GAAiB,QAAA;AAAA,IACjB,gBAAgB,gBAAA,GAAmB,cAAA;AAAA,IACnC,KAAA,EAAO,UAAU,UAAA,CAAW;AAAA,GAC9B,GAAI,IAAA;AAEJ,EAAA,IAAI,CAAC,QAAA,EAAU;AACb,IAAA,MAAM,IAAI,MAAM,0CAA0C,CAAA;AAAA,EAC5D;AACA,EAAA,IAAI,CAAC,MAAA,EAAQ;AACX,IAAA,MAAM,IAAI,MAAM,wCAAwC,CAAA;AAAA,EAC1D;AACA,EAAA,IAAI,CAAC,aAAA,EAAe;AAClB,IAAA,MAAM,IAAI,KAAA;AAAA,MACR;AAAA,KACF;AAAA,EACF;AAEA,EAAA,eAAe,OAAA,CACb,QACA,IAAA,EACY;AACZ,IAAA,MAAM,QAAA,GAAW,MAAM,OAAA,CAAQ,CAAA,EAAG,QAAQ,CAAA,IAAA,EAAO,QAAQ,CAAA,CAAA,EAAI,MAAM,CAAA,CAAA,EAAI;AAAA,MACrE,MAAA,EAAQ,MAAA;AAAA,MACR,OAAA,EAAS,EAAE,cAAA,EAAgB,kBAAA,EAAmB;AAAA,MAC9C,IAAA,EAAM,IAAA,CAAK,SAAA,CAAU,IAAI,CAAA;AAAA,MACzB,KAAA,EAAO;AAAA,KACR,CAAA;AAED,IAAA,MAAM,OAAA,GAAW,MAAM,QAAA,CAAS,IAAA,EAAK;AAMrC,IAAA,IAAI,CAAC,QAAA,CAAS,EAAA,IAAM,CAAC,QAAQ,EAAA,EAAI;AAC/B,MAAA,MAAM,IAAI,aAAA;AAAA,QACR,GAAG,MAAM,CAAA,SAAA,EAAY,OAAA,CAAQ,WAAA,IAAe,SAAS,MAAM,CAAA,CAAA;AAAA,QAC3D,OAAA,CAAQ;AAAA,OACV;AAAA,IACF;AAEA,IAAA,OAAO,OAAA,CAAQ,MAAA;AAAA,EACjB;AAEA,EAAA,OAAO;AAAA,IACL,MAAM,aAAa,KAAA,EAAwC;AACzD,MAAA,MAAM,IAAA,GAAO,iBAAiB,KAAK,CAAA;AACnC,MAAA,MAAM,SAAS,MAAM,OAAA;AAAA,QACnB,kBAAA;AAAA,QACA;AAAA,UACE,OAAA,EAAS,MAAA;AAAA,UACT,IAAA,EAAM,IAAA,CAAK,KAAA,CAAM,CAAA,EAAG,GAAG,CAAA;AAAA;AAAA;AAAA;AAAA,UAIvB,UAAA,EAAY;AAAA;AACd,OACF;AACA,MAAA,OAAO,MAAA,CAAO,iBAAA;AAAA,IAChB,CAAA;AAAA,IAEA,MAAM,IAAA,CAAK,QAAA,EAAkB,IAAA,EAA6B;AAExD,MAAA,MAAM,QAAQ,aAAA,EAAe;AAAA,QAC3B,OAAA,EAAS,MAAA;AAAA,QACT,iBAAA,EAAmB,QAAA;AAAA,QACnB;AAAA,OACD,CAAA;AAAA,IACH,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAOA,MAAM,YAAA,CAAa,QAAA,EAAkB,KAAA,EAAsC;AACzE,MAAA,MAAM,IAAA,GAAO,iBAAiB,KAAK,CAAA;AACnC,MAAA,MAAM,QAAQ,gBAAA,EAAkB;AAAA,QAC9B,OAAA,EAAS,MAAA;AAAA,QACT,iBAAA,EAAmB,QAAA;AAAA,QACnB,IAAA,EAAM,IAAA,CAAK,KAAA,CAAM,CAAA,EAAG,GAAG;AAAA,OACxB,CAAA;AAAA,IACH,CAAA;AAAA,IAEA,oBAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IASA,kBACE,MAAA,EAC8D;AAC9D,MAAA,IAAI,OAAO,MAAA,KAAW,QAAA,IAAY,MAAA,KAAW,MAAM,OAAO,IAAA;AAE1D,MAAA,MAAM,UAAW,MAAA,CAAiC,OAAA;AAClD,MAAA,IAAI,OAAO,OAAA,KAAY,QAAA,IAAY,OAAA,KAAY,MAAM,OAAO,IAAA;AAE5D,MAAA,MAAM;AAAA,QACJ,UAAA,EAAY,SAAA;AAAA,QACZ,iBAAA,EAAmB,QAAA;AAAA,QACnB,IAAA;AAAA,QACA,IAAA;AAAA,QACA;AAAA,OACF,GAAI,OAAA;AAQJ,MAAA,IAAI,IAAA,EAAM,MAAA,KAAW,IAAA,EAAM,OAAO,IAAA;AAClC,MAAA,IAAI,OAAO,QAAA,KAAa,QAAA,EAAU,OAAO,IAAA;AACzC,MAAA,IAAI,OAAO,SAAA,KAAc,QAAA,EAAU,OAAO,IAAA;AAC1C,MAAA,IAAI,OAAO,SAAS,QAAA,IAAY,IAAA,CAAK,MAAK,CAAE,MAAA,KAAW,GAAG,OAAO,IAAA;AAEjE,MAAA,IAAI,CAAC,MAAA,IAAU,MAAA,CAAO,MAAM,EAAE,CAAA,KAAM,QAAQ,OAAO,IAAA;AAEnD,MAAA,OAAO,EAAE,QAAA,EAAU,IAAA,EAAM,SAAA,EAAU;AAAA,IACrC,CAAA;AAAA,IAEA,cAAc,OAAA,EAA2B;AACvC,MAAA,MAAM,MAAA,GAAS,OAAA,CAAQ,OAAA,CAAQ,GAAA,CAAI,iCAAiC,CAAA;AACpE,MAAA,IAAI,CAAC,QAAQ,OAAO,KAAA;AACpB,MAAA,OAAO,MAAA,KAAW,aAAA;AAAA,IACpB;AAAA,GACF;AACF","file":"index.cjs","sourcesContent":["import type { ChatTransport, TopicNameInput } from '../core/transport-interface.js';\n\nexport class TelegramError extends Error {\n constructor(\n message: string,\n /** Telegram's own `description`, kept so callers can classify the failure. */\n readonly description?: string\n ) {\n super(message);\n }\n}\n\n/**\n * True when Telegram rejected the send because the forum topic no longer\n * exists — typically because the owner deleted it while tidying the group.\n * The session's threadId is then permanently dead and must be replaced.\n */\nexport function isMissingThreadError(error: unknown): boolean {\n if (!(error instanceof TelegramError)) return false;\n const detail = (error.description ?? error.message).toLowerCase();\n return (\n detail.includes('thread not found') ||\n detail.includes('topic_deleted') ||\n detail.includes('topic was deleted') ||\n detail.includes('thread_not_found')\n );\n}\n\nconst API_BASE = 'https://api.telegram.org';\n\nexport function describeBrowser(userAgent: string | null): string {\n if (!userAgent) return 'Unknown browser';\n if (/edg\\//i.test(userAgent)) return 'Edge';\n if (/opr\\//i.test(userAgent)) return 'Opera';\n if (/chrome\\//i.test(userAgent)) return 'Chrome';\n if (/firefox\\//i.test(userAgent)) return 'Firefox';\n if (/safari\\//i.test(userAgent)) return 'Safari';\n return 'Unknown browser';\n}\n\n/**\n * Produces e.g. \"Roman-04-08-17-42-Chrome\".\n *\n * Date and time come from Intl with an explicit timeZone: the server runs UTC,\n * so reading Date methods directly would stamp titles three hours off Kyiv.\n */\nexport function buildTopicName(input: TopicNameInput): string {\n const parts = new Intl.DateTimeFormat('en-GB', {\n timeZone: 'Europe/Kyiv',\n day: '2-digit',\n month: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n hour12: false,\n }).formatToParts(input.now);\n\n const at = (type: Intl.DateTimeFormatPartTypes) =>\n parts.find((part) => part.type === type)?.value ?? '00';\n\n return [\n input.name,\n at('day'),\n at('month'),\n at('hour'),\n at('minute'),\n describeBrowser(input.userAgent),\n ]\n .join('-')\n .slice(0, 128);\n}\n\nexport interface TelegramTransportOptions {\n botToken: string;\n chatId: string;\n /** The transport verifies inbound webhooks by comparing this against the x-telegram-bot-api-secret-token header. */\n webhookSecret: string;\n /** Telegram's fixed six-colour palette. Default 0xcb86db (purple). */\n topicIconColor?: number;\n /** Override the default topic-name format. Default: `${name}-DD-MM-HH-MM-${browser}` in Europe/Kyiv, sliced to 128 chars. */\n buildTopicName?: (input: TopicNameInput) => string;\n /** Optional fetch override for tests / edge runtimes. Default: globalThis.fetch. */\n fetch?: typeof fetch;\n}\n\nexport function telegramTransport(opts: TelegramTransportOptions): ChatTransport {\n const {\n botToken,\n chatId,\n webhookSecret,\n topicIconColor = 0xcb86db,\n buildTopicName: buildTopicNameFn = buildTopicName,\n fetch: fetchFn = globalThis.fetch,\n } = opts;\n\n if (!botToken) {\n throw new Error('telegramTransport: botToken is required.');\n }\n if (!chatId) {\n throw new Error('telegramTransport: chatId is required.');\n }\n if (!webhookSecret) {\n throw new Error(\n 'telegramTransport: webhookSecret is required. Pass the same value you configured with Telegram via /setWebhook.',\n );\n }\n\n async function callApi<T>(\n method: string,\n body: Record<string, unknown>\n ): Promise<T> {\n const response = await fetchFn(`${API_BASE}/bot${botToken}/${method}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n cache: 'no-store',\n });\n\n const payload = (await response.json()) as {\n ok: boolean;\n result?: T;\n description?: string;\n };\n\n if (!response.ok || !payload.ok) {\n throw new TelegramError(\n `${method} failed: ${payload.description ?? response.status}`,\n payload.description\n );\n }\n\n return payload.result as T;\n }\n\n return {\n async createThread(input: TopicNameInput): Promise<number> {\n const name = buildTopicNameFn(input);\n const result = await callApi<{ message_thread_id: number }>(\n 'createForumTopic',\n {\n chat_id: chatId,\n name: name.slice(0, 128),\n // Telegram snaps icon_color to a fixed six-colour palette and silently\n // substitutes the nearest allowed value. 0xCB86DB is the purple one;\n // an arbitrary brand hex such as 0x9333EA comes back as blue.\n icon_color: topicIconColor,\n }\n );\n return result.message_thread_id;\n },\n\n async send(threadId: number, text: string): Promise<void> {\n // No parse_mode: visitor text must never be parsed as Markdown or HTML.\n await callApi('sendMessage', {\n chat_id: chatId,\n message_thread_id: threadId,\n text,\n });\n },\n\n /**\n * Retitles an existing topic. Needed when a session created before the name\n * gate shipped supplies a name for the first time — the topic already exists,\n * so it has to be renamed rather than created.\n */\n async renameThread(threadId: number, input: TopicNameInput): Promise<void> {\n const name = buildTopicNameFn(input);\n await callApi('editForumTopic', {\n chat_id: chatId,\n message_thread_id: threadId,\n name: name.slice(0, 128),\n });\n },\n\n isMissingThreadError,\n\n /**\n * Narrows an inbound webhook update to an owner reply, or null.\n *\n * Every inbound filter lives here: not a message, sent by a bot, missing a\n * message_thread_id (i.e. posted in the group's General area rather than a\n * topic), or from an unexpected chat all return null.\n */\n parseInboundReply(\n update: unknown\n ): { threadId: number; text: string; messageId: number } | null {\n if (typeof update !== 'object' || update === null) return null;\n\n const message = (update as { message?: unknown }).message;\n if (typeof message !== 'object' || message === null) return null;\n\n const {\n message_id: messageId,\n message_thread_id: threadId,\n text,\n chat,\n from,\n } = message as {\n message_id?: unknown;\n message_thread_id?: unknown;\n text?: unknown;\n chat?: { id?: unknown };\n from?: { is_bot?: unknown };\n };\n\n if (from?.is_bot === true) return null;\n if (typeof threadId !== 'number') return null;\n if (typeof messageId !== 'number') return null;\n if (typeof text !== 'string' || text.trim().length === 0) return null;\n\n if (!chatId || String(chat?.id) !== chatId) return null;\n\n return { threadId, text, messageId };\n },\n\n verifyWebhook(request: Request): boolean {\n const header = request.headers.get('x-telegram-bot-api-secret-token');\n if (!header) return false;\n return header === webhookSecret;\n },\n };\n}\n"]}
@@ -0,0 +1,48 @@
1
+ interface TopicNameInput {
2
+ name: string;
3
+ userAgent: string | null;
4
+ now: Date;
5
+ }
6
+ interface ChatTransport {
7
+ createThread(input: TopicNameInput): Promise<number>;
8
+ renameThread(threadId: number, input: TopicNameInput): Promise<void>;
9
+ send(threadId: number, text: string): Promise<void>;
10
+ isMissingThreadError(error: unknown): boolean;
11
+ parseInboundReply(update: unknown): {
12
+ threadId: number;
13
+ text: string;
14
+ messageId: number;
15
+ } | null;
16
+ verifyWebhook(request: Request): boolean;
17
+ }
18
+
19
+ declare class TelegramError extends Error {
20
+ /** Telegram's own `description`, kept so callers can classify the failure. */
21
+ readonly description?: string | undefined;
22
+ constructor(message: string,
23
+ /** Telegram's own `description`, kept so callers can classify the failure. */
24
+ description?: string | undefined);
25
+ }
26
+ declare function describeBrowser(userAgent: string | null): string;
27
+ /**
28
+ * Produces e.g. "Roman-04-08-17-42-Chrome".
29
+ *
30
+ * Date and time come from Intl with an explicit timeZone: the server runs UTC,
31
+ * so reading Date methods directly would stamp titles three hours off Kyiv.
32
+ */
33
+ declare function buildTopicName(input: TopicNameInput): string;
34
+ interface TelegramTransportOptions {
35
+ botToken: string;
36
+ chatId: string;
37
+ /** The transport verifies inbound webhooks by comparing this against the x-telegram-bot-api-secret-token header. */
38
+ webhookSecret: string;
39
+ /** Telegram's fixed six-colour palette. Default 0xcb86db (purple). */
40
+ topicIconColor?: number;
41
+ /** Override the default topic-name format. Default: `${name}-DD-MM-HH-MM-${browser}` in Europe/Kyiv, sliced to 128 chars. */
42
+ buildTopicName?: (input: TopicNameInput) => string;
43
+ /** Optional fetch override for tests / edge runtimes. Default: globalThis.fetch. */
44
+ fetch?: typeof fetch;
45
+ }
46
+ declare function telegramTransport(opts: TelegramTransportOptions): ChatTransport;
47
+
48
+ export { type ChatTransport, TelegramError, type TelegramTransportOptions, type TopicNameInput, buildTopicName, describeBrowser, telegramTransport };
@@ -0,0 +1,48 @@
1
+ interface TopicNameInput {
2
+ name: string;
3
+ userAgent: string | null;
4
+ now: Date;
5
+ }
6
+ interface ChatTransport {
7
+ createThread(input: TopicNameInput): Promise<number>;
8
+ renameThread(threadId: number, input: TopicNameInput): Promise<void>;
9
+ send(threadId: number, text: string): Promise<void>;
10
+ isMissingThreadError(error: unknown): boolean;
11
+ parseInboundReply(update: unknown): {
12
+ threadId: number;
13
+ text: string;
14
+ messageId: number;
15
+ } | null;
16
+ verifyWebhook(request: Request): boolean;
17
+ }
18
+
19
+ declare class TelegramError extends Error {
20
+ /** Telegram's own `description`, kept so callers can classify the failure. */
21
+ readonly description?: string | undefined;
22
+ constructor(message: string,
23
+ /** Telegram's own `description`, kept so callers can classify the failure. */
24
+ description?: string | undefined);
25
+ }
26
+ declare function describeBrowser(userAgent: string | null): string;
27
+ /**
28
+ * Produces e.g. "Roman-04-08-17-42-Chrome".
29
+ *
30
+ * Date and time come from Intl with an explicit timeZone: the server runs UTC,
31
+ * so reading Date methods directly would stamp titles three hours off Kyiv.
32
+ */
33
+ declare function buildTopicName(input: TopicNameInput): string;
34
+ interface TelegramTransportOptions {
35
+ botToken: string;
36
+ chatId: string;
37
+ /** The transport verifies inbound webhooks by comparing this against the x-telegram-bot-api-secret-token header. */
38
+ webhookSecret: string;
39
+ /** Telegram's fixed six-colour palette. Default 0xcb86db (purple). */
40
+ topicIconColor?: number;
41
+ /** Override the default topic-name format. Default: `${name}-DD-MM-HH-MM-${browser}` in Europe/Kyiv, sliced to 128 chars. */
42
+ buildTopicName?: (input: TopicNameInput) => string;
43
+ /** Optional fetch override for tests / edge runtimes. Default: globalThis.fetch. */
44
+ fetch?: typeof fetch;
45
+ }
46
+ declare function telegramTransport(opts: TelegramTransportOptions): ChatTransport;
47
+
48
+ export { type ChatTransport, TelegramError, type TelegramTransportOptions, type TopicNameInput, buildTopicName, describeBrowser, telegramTransport };