hood-alerts 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 (107) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +369 -0
  3. package/dist/bot/index.cjs +1008 -0
  4. package/dist/bot/index.cjs.map +1 -0
  5. package/dist/bot/index.d.cts +241 -0
  6. package/dist/bot/index.d.ts +241 -0
  7. package/dist/bot/index.js +30 -0
  8. package/dist/bot/index.js.map +1 -0
  9. package/dist/chunk-32GS6XVE.js +732 -0
  10. package/dist/chunk-32GS6XVE.js.map +1 -0
  11. package/dist/chunk-3LHCQA4Z.js +76 -0
  12. package/dist/chunk-3LHCQA4Z.js.map +1 -0
  13. package/dist/chunk-3NHLSKZE.js +1 -0
  14. package/dist/chunk-3NHLSKZE.js.map +1 -0
  15. package/dist/chunk-6JQK5G3Z.js +72 -0
  16. package/dist/chunk-6JQK5G3Z.js.map +1 -0
  17. package/dist/chunk-CBNZ6AZT.js +598 -0
  18. package/dist/chunk-CBNZ6AZT.js.map +1 -0
  19. package/dist/chunk-CIURPPOW.js +1 -0
  20. package/dist/chunk-CIURPPOW.js.map +1 -0
  21. package/dist/chunk-IUOEGBF6.js +49 -0
  22. package/dist/chunk-IUOEGBF6.js.map +1 -0
  23. package/dist/chunk-NBMINJ5E.js +230 -0
  24. package/dist/chunk-NBMINJ5E.js.map +1 -0
  25. package/dist/chunk-O2RY2SWN.js +1 -0
  26. package/dist/chunk-O2RY2SWN.js.map +1 -0
  27. package/dist/chunk-PLMVKTQM.js +1 -0
  28. package/dist/chunk-PLMVKTQM.js.map +1 -0
  29. package/dist/chunk-RSTLW7XH.js +130 -0
  30. package/dist/chunk-RSTLW7XH.js.map +1 -0
  31. package/dist/chunk-SOBZ2FPP.js +355 -0
  32. package/dist/chunk-SOBZ2FPP.js.map +1 -0
  33. package/dist/chunk-VC5MKEY2.js +170 -0
  34. package/dist/chunk-VC5MKEY2.js.map +1 -0
  35. package/dist/chunk-W2GUAWUR.js +147 -0
  36. package/dist/chunk-W2GUAWUR.js.map +1 -0
  37. package/dist/chunk-Y2M3USWZ.js +355 -0
  38. package/dist/chunk-Y2M3USWZ.js.map +1 -0
  39. package/dist/chunk-Z4DJPESQ.js +1216 -0
  40. package/dist/chunk-Z4DJPESQ.js.map +1 -0
  41. package/dist/chunk-ZDM5VNWB.js +1 -0
  42. package/dist/chunk-ZDM5VNWB.js.map +1 -0
  43. package/dist/events/index.cjs +820 -0
  44. package/dist/events/index.cjs.map +1 -0
  45. package/dist/events/index.d.cts +58 -0
  46. package/dist/events/index.d.ts +58 -0
  47. package/dist/events/index.js +69 -0
  48. package/dist/events/index.js.map +1 -0
  49. package/dist/format-6RG4OcyE.d.ts +57 -0
  50. package/dist/format-B7d40f65.d.cts +57 -0
  51. package/dist/index.cjs +4122 -0
  52. package/dist/index.cjs.map +1 -0
  53. package/dist/index.d.cts +21 -0
  54. package/dist/index.d.ts +21 -0
  55. package/dist/index.js +274 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/logger-3phvV_fl.d.cts +185 -0
  58. package/dist/logger-CYza_0eU.d.ts +185 -0
  59. package/dist/logs-D7sYBvSy.d.cts +66 -0
  60. package/dist/logs-D7sYBvSy.d.ts +66 -0
  61. package/dist/notifiers/index.cjs +620 -0
  62. package/dist/notifiers/index.cjs.map +1 -0
  63. package/dist/notifiers/index.d.cts +186 -0
  64. package/dist/notifiers/index.d.ts +186 -0
  65. package/dist/notifiers/index.js +70 -0
  66. package/dist/notifiers/index.js.map +1 -0
  67. package/dist/pricing-BumgLpvB.d.cts +79 -0
  68. package/dist/pricing-BumgLpvB.d.ts +79 -0
  69. package/dist/ratelimit-BKVQG2oY.d.cts +42 -0
  70. package/dist/ratelimit-CGLlXNIS.d.ts +42 -0
  71. package/dist/registry-D3We8Lg6.d.ts +50 -0
  72. package/dist/registry-DePA3IyS.d.cts +50 -0
  73. package/dist/rules/index.cjs +614 -0
  74. package/dist/rules/index.cjs.map +1 -0
  75. package/dist/rules/index.d.cts +166 -0
  76. package/dist/rules/index.d.ts +166 -0
  77. package/dist/rules/index.js +60 -0
  78. package/dist/rules/index.js.map +1 -0
  79. package/dist/schema-DuDMPqpq.d.ts +183 -0
  80. package/dist/schema-NnIdKFl2.d.cts +183 -0
  81. package/dist/service/index.cjs +3774 -0
  82. package/dist/service/index.cjs.map +1 -0
  83. package/dist/service/index.d.cts +262 -0
  84. package/dist/service/index.d.ts +262 -0
  85. package/dist/service/index.js +45 -0
  86. package/dist/service/index.js.map +1 -0
  87. package/dist/service/main.cjs +3759 -0
  88. package/dist/service/main.cjs.map +1 -0
  89. package/dist/service/main.d.cts +1 -0
  90. package/dist/service/main.d.ts +1 -0
  91. package/dist/service/main.js +60 -0
  92. package/dist/service/main.js.map +1 -0
  93. package/dist/sources-CAaQR6-N.d.ts +159 -0
  94. package/dist/sources-CpDOUlzx.d.cts +159 -0
  95. package/dist/telegram-CpodYJrP.d.cts +64 -0
  96. package/dist/telegram-pvphmBwn.d.ts +64 -0
  97. package/dist/tiers/index.cjs +471 -0
  98. package/dist/tiers/index.cjs.map +1 -0
  99. package/dist/tiers/index.d.cts +231 -0
  100. package/dist/tiers/index.d.ts +231 -0
  101. package/dist/tiers/index.js +34 -0
  102. package/dist/tiers/index.js.map +1 -0
  103. package/dist/types-C0ETog04.d.ts +109 -0
  104. package/dist/types-CcoKpStA.d.cts +109 -0
  105. package/dist/types-DFPpTMWo.d.cts +186 -0
  106. package/dist/types-DFPpTMWo.d.ts +186 -0
  107. package/package.json +114 -0
@@ -0,0 +1,1008 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/bot/index.ts
21
+ var bot_exports = {};
22
+ __export(bot_exports, {
23
+ CALLBACK_TYPE: () => CALLBACK_TYPE,
24
+ COMMANDS: () => COMMANDS,
25
+ EPHEMERAL_FLAG: () => EPHEMERAL_FLAG,
26
+ INTERACTION_TYPE: () => INTERACTION_TYPE,
27
+ createCommandRouter: () => createCommandRouter,
28
+ createDiscordInteractionHandler: () => createDiscordInteractionHandler,
29
+ createTelegramBot: () => createTelegramBot,
30
+ discordCommandDefinitions: () => discordCommandDefinitions,
31
+ registerDiscordCommands: () => registerDiscordCommands,
32
+ verifyDiscordSignature: () => verifyDiscordSignature
33
+ });
34
+ module.exports = __toCommonJS(bot_exports);
35
+
36
+ // src/bot/commands.ts
37
+ var import_node_crypto = require("crypto");
38
+ var import_viem3 = require("viem");
39
+
40
+ // src/notifiers/escape.ts
41
+ function escapeHtml(text) {
42
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
43
+ }
44
+ function truncate(text, max) {
45
+ if (max <= 0) return "";
46
+ const chars = [...text];
47
+ if (chars.length <= max) return text;
48
+ if (max === 1) return "\u2026";
49
+ return `${chars.slice(0, max - 1).join("")}\u2026`;
50
+ }
51
+
52
+ // src/rules/schema.ts
53
+ var import_zod = require("zod");
54
+
55
+ // src/events/types.ts
56
+ var LAUNCHPAD_EVENT_KINDS = {
57
+ noxa: ["launch", "whale_trade"],
58
+ odyssey: ["launch", "curve_trade", "graduation", "whale_trade"]
59
+ };
60
+
61
+ // src/rules/schema.ts
62
+ var addressPattern = /^0x[0-9a-fA-F]{40}$/;
63
+ var addressSchema = import_zod.z.string().regex(addressPattern, "must be a 0x-prefixed 20-byte address").transform((value) => value.toLowerCase());
64
+ var eventKindSchema = import_zod.z.enum(["launch", "curve_trade", "graduation", "whale_trade"]);
65
+ var launchpadSchema = import_zod.z.enum(["noxa", "odyssey"]);
66
+ var sideSchema = import_zod.z.enum(["buy", "sell", "any"]);
67
+ var reputationFilterSchema = import_zod.z.object({
68
+ /** Require the deployer to have launched at least this many tokens before. */
69
+ minPriorLaunches: import_zod.z.number().int().min(0).max(1e4).optional(),
70
+ /** Reject serial deployers above this prior-launch count. */
71
+ maxPriorLaunches: import_zod.z.number().int().min(0).max(1e4).optional(),
72
+ /**
73
+ * Reject a deployer with more than this many prior launches whose pool
74
+ * liquidity has since been drained below the rug threshold.
75
+ */
76
+ maxRuggedLaunches: import_zod.z.number().int().min(0).max(1e4).optional(),
77
+ /**
78
+ * Require this launch's own LP position to be held by the launchpad's
79
+ * locker contract. Only meaningful for `launch` events that carry a pool
80
+ * (NOXA); events without a pool never satisfy it.
81
+ */
82
+ requireLpLocked: import_zod.z.boolean().optional()
83
+ }).strict().refine(
84
+ (value) => value.minPriorLaunches === void 0 || value.maxPriorLaunches === void 0 || value.minPriorLaunches <= value.maxPriorLaunches,
85
+ { message: "minPriorLaunches must not exceed maxPriorLaunches" }
86
+ );
87
+ var rateLimitSchema = import_zod.z.object({
88
+ /** Hard cap on alerts this rule may deliver per rolling hour. */
89
+ maxPerHour: import_zod.z.number().int().min(1).max(1e4).optional(),
90
+ /** Minimum gap between two alerts from this rule, in seconds. */
91
+ minIntervalSeconds: import_zod.z.number().int().min(1).max(86400).optional()
92
+ }).strict();
93
+ var ruleSchema = import_zod.z.object({
94
+ /** Stable id, unique within a subscription. Used in bot commands. */
95
+ id: import_zod.z.string().min(1).max(32).regex(/^[a-z0-9][a-z0-9_-]*$/, "must be lowercase letters, digits, hyphen or underscore"),
96
+ /** Human label shown in `/rules`. */
97
+ name: import_zod.z.string().min(1).max(64).optional(),
98
+ enabled: import_zod.z.boolean().default(true),
99
+ /** Event kinds this rule reacts to. Defaults to all four. */
100
+ kinds: import_zod.z.array(eventKindSchema).min(1).max(4).default(["launch", "curve_trade", "graduation", "whale_trade"]),
101
+ /** Launchpads this rule reacts to. Defaults to both. */
102
+ launchpads: import_zod.z.array(launchpadSchema).min(1).max(2).default(["noxa", "odyssey"]),
103
+ /** Minimum USD value of the event. Events with an unknown value never pass. */
104
+ minUsd: import_zod.z.number().min(0).max(1e12).optional(),
105
+ /** Maximum USD value of the event. */
106
+ maxUsd: import_zod.z.number().min(0).max(1e12).optional(),
107
+ /** Minimum USD liquidity of the token's deepest known pool. */
108
+ minLiquidityUsd: import_zod.z.number().min(0).max(1e12).optional(),
109
+ /** Maximum USD liquidity of the token's deepest known pool. */
110
+ maxLiquidityUsd: import_zod.z.number().min(0).max(1e12).optional(),
111
+ /** Watchlist. When non-empty, only these tokens match. */
112
+ tokens: import_zod.z.array(addressSchema).max(500).default([]),
113
+ /** Deployer watchlist. When non-empty, only these actors match. */
114
+ deployers: import_zod.z.array(addressSchema).max(500).default([]),
115
+ /** Deployers to always reject, applied after every other filter. */
116
+ excludeDeployers: import_zod.z.array(addressSchema).max(500).default([]),
117
+ /** Trade direction. Ignored by `launch` and `graduation` events. */
118
+ side: sideSchema.default("any"),
119
+ reputation: reputationFilterSchema.optional(),
120
+ rateLimit: rateLimitSchema.optional()
121
+ }).strict().refine((rule) => rule.minUsd === void 0 || rule.maxUsd === void 0 || rule.minUsd <= rule.maxUsd, {
122
+ message: "minUsd must not exceed maxUsd",
123
+ path: ["minUsd"]
124
+ }).refine(
125
+ (rule) => rule.minLiquidityUsd === void 0 || rule.maxLiquidityUsd === void 0 || rule.minLiquidityUsd <= rule.maxLiquidityUsd,
126
+ { message: "minLiquidityUsd must not exceed maxLiquidityUsd", path: ["minLiquidityUsd"] }
127
+ ).refine((rule) => rule.kinds.some((kind) => rule.launchpads.some((pad) => canEmit(pad, kind))), {
128
+ message: "this combination can never fire: NOXA is an instant launcher with no bonding curve, so it emits no curve_trade and no graduation",
129
+ path: ["kinds"]
130
+ });
131
+ var platformSchema = import_zod.z.enum(["telegram", "discord"]);
132
+ var subscriptionSchema = import_zod.z.object({
133
+ id: import_zod.z.string().min(1).max(64),
134
+ /**
135
+ * The account that owns this subscription, and the unit entitlements are
136
+ * checked against. For Telegram this is the user id, for Discord the guild
137
+ * id (a server pays once for its channels) or user id for a DM.
138
+ */
139
+ subscriberId: import_zod.z.string().min(1).max(128),
140
+ platform: platformSchema,
141
+ /**
142
+ * Where alerts go: a Telegram chat id, a Discord channel id, or a Discord
143
+ * webhook URL. Validated per platform by the notifier, not here, because
144
+ * the same subscription record serves all three shapes.
145
+ */
146
+ target: import_zod.z.string().min(1).max(512),
147
+ enabled: import_zod.z.boolean().default(true),
148
+ rules: import_zod.z.array(ruleSchema).max(100).default([]),
149
+ createdAtMs: import_zod.z.number().int().nonnegative()
150
+ }).strict().refine((sub) => new Set(sub.rules.map((rule) => rule.id)).size === sub.rules.length, {
151
+ message: "rule ids must be unique within a subscription",
152
+ path: ["rules"]
153
+ });
154
+ function canEmit(launchpad, kind) {
155
+ return LAUNCHPAD_EVENT_KINDS[launchpad].includes(kind);
156
+ }
157
+ function ruleComplexity(rule) {
158
+ let score = 0;
159
+ if (rule.minUsd !== void 0) score += 1;
160
+ if (rule.maxUsd !== void 0) score += 1;
161
+ if (rule.minLiquidityUsd !== void 0) score += 1;
162
+ if (rule.maxLiquidityUsd !== void 0) score += 1;
163
+ if (rule.tokens.length > 0) score += 1;
164
+ if (rule.deployers.length > 0) score += 1;
165
+ if (rule.excludeDeployers.length > 0) score += 1;
166
+ if (rule.side !== "any") score += 1;
167
+ if (rule.reputation) score += Object.values(rule.reputation).filter((v) => v !== void 0).length;
168
+ if (rule.rateLimit) score += 1;
169
+ return score;
170
+ }
171
+ function parseRule(input) {
172
+ return ruleSchema.parse(input);
173
+ }
174
+ function safeParseRule(input) {
175
+ return ruleSchema.safeParse(input);
176
+ }
177
+ function parseSubscription(input) {
178
+ return subscriptionSchema.parse(input);
179
+ }
180
+ function formatValidationError(error) {
181
+ return error.issues.map((issue) => {
182
+ const path = issue.path.join(".");
183
+ return path ? `${path}: ${issue.message}` : issue.message;
184
+ }).join("; ");
185
+ }
186
+ function defaultRules() {
187
+ return [
188
+ parseRule({ id: "launches", name: "New launches", kinds: ["launch"] }),
189
+ parseRule({
190
+ id: "graduations",
191
+ name: "Odyssey graduations",
192
+ kinds: ["graduation"],
193
+ launchpads: ["odyssey"]
194
+ }),
195
+ parseRule({ id: "whales", name: "Whale trades over $5k", kinds: ["whale_trade"], minUsd: 5e3 })
196
+ ];
197
+ }
198
+
199
+ // src/tiers/entitlements.ts
200
+ var import_viem = require("viem");
201
+ var import_hoodchain = require("hoodchain");
202
+ var import_viem2 = require("viem");
203
+ function linkMessage(subscriberId, nonce) {
204
+ return `hood-alerts wallet link
205
+ subscriber: ${subscriberId}
206
+ nonce: ${nonce}`;
207
+ }
208
+ async function verifyWalletLink(subscriberId, nonce, address, signature) {
209
+ if (!/^0x[0-9a-fA-F]{130}$/.test(signature)) return null;
210
+ if (!/^0x[0-9a-fA-F]{40}$/.test(address)) return null;
211
+ try {
212
+ const valid = await (0, import_viem.verifyMessage)({
213
+ address: (0, import_viem.getAddress)(address),
214
+ message: linkMessage(subscriberId, nonce),
215
+ signature
216
+ });
217
+ return valid ? (0, import_viem.getAddress)(address) : null;
218
+ } catch {
219
+ return null;
220
+ }
221
+ }
222
+
223
+ // src/bot/commands.ts
224
+ var reply = (text) => ({ html: escapeHtml(text), text });
225
+ var html = (htmlText, plain) => ({ html: htmlText, text: plain });
226
+ var COMMANDS = [
227
+ { name: "start", description: "Set up alerts in this chat", usage: "/start" },
228
+ { name: "help", description: "Show every command", usage: "/help" },
229
+ {
230
+ name: "subscribe",
231
+ description: "Send alerts here, with a starter rule set",
232
+ usage: "/subscribe [target]",
233
+ options: [
234
+ {
235
+ name: "target",
236
+ description: "Discord webhook URL or channel id. Defaults to this channel.",
237
+ required: false
238
+ }
239
+ ]
240
+ },
241
+ {
242
+ name: "unsubscribe",
243
+ description: "Stop alerts for a subscription",
244
+ usage: "/unsubscribe [subscription id]",
245
+ options: [{ name: "id", description: "Subscription id from /status", required: false }]
246
+ },
247
+ { name: "rules", description: "List your rules", usage: "/rules" },
248
+ {
249
+ name: "rule",
250
+ description: "Add, remove, enable or disable a rule",
251
+ usage: "/rule add <json> | /rule rm <id> | /rule on <id> | /rule off <id>",
252
+ options: [{ name: "input", description: "add <json> | rm <id> | on <id> | off <id>", required: true }]
253
+ },
254
+ {
255
+ name: "threshold",
256
+ description: "Set a rule minimum USD value",
257
+ usage: "/threshold <rule id> <usd>",
258
+ options: [
259
+ { name: "rule", description: "Rule id", required: true },
260
+ { name: "usd", description: "Minimum USD value", required: true }
261
+ ]
262
+ },
263
+ {
264
+ name: "watch",
265
+ description: "Add a token to a rule watchlist",
266
+ usage: "/watch <token address> [rule id]",
267
+ options: [
268
+ { name: "token", description: "Token contract address", required: true },
269
+ { name: "rule", description: "Rule id (defaults to the first rule)", required: false }
270
+ ]
271
+ },
272
+ {
273
+ name: "unwatch",
274
+ description: "Remove a token from a rule watchlist",
275
+ usage: "/unwatch <token address> [rule id]",
276
+ options: [
277
+ { name: "token", description: "Token contract address", required: true },
278
+ { name: "rule", description: "Rule id (defaults to the first rule)", required: false }
279
+ ]
280
+ },
281
+ { name: "tier", description: "Show your tier and its limits", usage: "/tier" },
282
+ { name: "status", description: "Show your subscriptions and delivery state", usage: "/status" },
283
+ {
284
+ name: "link",
285
+ description: "Link a paying wallet (signature verified)",
286
+ usage: "/link [address] [signature]",
287
+ options: [
288
+ { name: "address", description: "Wallet address that pays", required: false },
289
+ { name: "signature", description: "Signature of the link message", required: false }
290
+ ]
291
+ },
292
+ { name: "upgrade", description: "How to go premium", usage: "/upgrade" }
293
+ ];
294
+ function ruleSummary(rule) {
295
+ const parts = [
296
+ `${rule.enabled ? "on " : "off"} ${rule.id}`,
297
+ rule.name ? `"${rule.name}"` : null,
298
+ `kinds=${rule.kinds.join("/")}`,
299
+ `pads=${rule.launchpads.join("/")}`,
300
+ rule.minUsd !== void 0 ? `minUsd=${rule.minUsd}` : null,
301
+ rule.maxUsd !== void 0 ? `maxUsd=${rule.maxUsd}` : null,
302
+ rule.minLiquidityUsd !== void 0 ? `minLiq=${rule.minLiquidityUsd}` : null,
303
+ rule.maxLiquidityUsd !== void 0 ? `maxLiq=${rule.maxLiquidityUsd}` : null,
304
+ rule.side !== "any" ? `side=${rule.side}` : null,
305
+ rule.tokens.length > 0 ? `tokens=${rule.tokens.length}` : null,
306
+ rule.deployers.length > 0 ? `deployers=${rule.deployers.length}` : null,
307
+ rule.rateLimit ? `rate=${JSON.stringify(rule.rateLimit)}` : null,
308
+ `filters=${ruleComplexity(rule)}`
309
+ ].filter((part) => part !== null);
310
+ return parts.join(" ");
311
+ }
312
+ function createCommandRouter(deps) {
313
+ const now = deps.now ?? Date.now;
314
+ const newId = deps.newId ?? import_node_crypto.randomUUID;
315
+ function subscriptionsOf(context) {
316
+ return deps.store.listSubscriptionsFor(context.subscriberId);
317
+ }
318
+ function firstSubscription(context) {
319
+ return subscriptionsOf(context)[0] ?? null;
320
+ }
321
+ async function handleSubscribe(context) {
322
+ const target = context.args[0]?.trim() || context.defaultTarget;
323
+ if (!target) {
324
+ return reply(
325
+ "I could not work out where to send alerts. Pass a target: /subscribe <discord webhook URL or channel id>"
326
+ );
327
+ }
328
+ const existing = subscriptionsOf(context);
329
+ if (existing.some((sub) => sub.target === target)) {
330
+ return reply(`This target is already subscribed. Use /rules to edit it, or /unsubscribe to stop.`);
331
+ }
332
+ const decision = await deps.gate.checkAddSubscription(context.subscriberId, existing.length);
333
+ if (!decision.allowed) return reply(`Cannot subscribe: ${decision.reason}`);
334
+ const { policy } = await deps.gate.resolve(context.subscriberId);
335
+ const rules = defaultRules().filter(
336
+ (rule) => rule.kinds.every((kind) => policy.allowedKinds.includes(kind))
337
+ );
338
+ const subscription = parseSubscription({
339
+ id: newId(),
340
+ subscriberId: context.subscriberId,
341
+ platform: context.platform,
342
+ target,
343
+ enabled: true,
344
+ rules: rules.slice(0, policy.maxRulesPerSubscription),
345
+ createdAtMs: now()
346
+ });
347
+ deps.store.saveSubscription(subscription);
348
+ return html(
349
+ [
350
+ `<b>Subscribed.</b> Alerts will arrive here.`,
351
+ `Subscription id: <code>${escapeHtml(subscription.id)}</code>`,
352
+ `Tier: <b>${policy.tier}</b> (delivery delay ${policy.deliveryDelayMs / 1e3}s, ${policy.maxAlertsPerHour} alerts/hour)`,
353
+ "",
354
+ "Starter rules:",
355
+ ...subscription.rules.map((rule) => `\u2022 <code>${escapeHtml(ruleSummary(rule))}</code>`),
356
+ "",
357
+ "Edit them with /threshold, /watch and /rule. See /help."
358
+ ].join("\n"),
359
+ `Subscribed. id=${subscription.id} tier=${policy.tier}`
360
+ );
361
+ }
362
+ async function handleUnsubscribe(context) {
363
+ const subs = subscriptionsOf(context);
364
+ if (subs.length === 0) return reply("You have no subscriptions. Use /subscribe to start one.");
365
+ const id = context.args[0]?.trim();
366
+ if (!id) {
367
+ if (subs.length > 1) {
368
+ return html(
369
+ [
370
+ "You have several subscriptions. Name the one to remove:",
371
+ ...subs.map((sub) => `\u2022 <code>${escapeHtml(sub.id)}</code> \u2192 ${escapeHtml(sub.target)}`),
372
+ "",
373
+ "Usage: /unsubscribe &lt;subscription id&gt;"
374
+ ].join("\n"),
375
+ "Name the subscription to remove: " + subs.map((sub) => sub.id).join(", ")
376
+ );
377
+ }
378
+ const only = subs[0];
379
+ deps.store.deleteSubscription(only.id);
380
+ return reply("Unsubscribed. No more alerts will be delivered here.");
381
+ }
382
+ const match = subs.find((sub) => sub.id === id);
383
+ if (!match) return reply(`No subscription with id "${id}". Use /status to list yours.`);
384
+ deps.store.deleteSubscription(match.id);
385
+ return reply(`Unsubscribed ${match.id}.`);
386
+ }
387
+ function handleRules(context) {
388
+ const subs = subscriptionsOf(context);
389
+ if (subs.length === 0) return reply("You have no subscriptions yet. Use /subscribe to start one.");
390
+ const lines = [];
391
+ for (const sub of subs) {
392
+ lines.push(`<b>${escapeHtml(sub.target)}</b> (<code>${escapeHtml(sub.id)}</code>)`);
393
+ if (sub.rules.length === 0) lines.push(" no rules: nothing will be delivered");
394
+ for (const rule of sub.rules) lines.push(` \u2022 <code>${escapeHtml(ruleSummary(rule))}</code>`);
395
+ }
396
+ return html(lines.join("\n"), lines.join("\n").replace(/<[^>]+>/g, ""));
397
+ }
398
+ async function handleRule(context) {
399
+ const action = context.args[0]?.toLowerCase();
400
+ const subscription = firstSubscription(context);
401
+ if (!subscription) return reply("Subscribe first with /subscribe, then add rules.");
402
+ if (action === "add") {
403
+ const json = context.rest.slice(context.rest.indexOf("add") + 3).trim();
404
+ if (!json) {
405
+ return html(
406
+ [
407
+ 'Usage: <code>/rule add {"id":"big-whales","kinds":["whale_trade"],"minUsd":25000}</code>',
408
+ "",
409
+ "Fields: id, name, enabled, kinds, launchpads, minUsd, maxUsd, minLiquidityUsd,",
410
+ "maxLiquidityUsd, tokens, deployers, excludeDeployers, side, reputation, rateLimit."
411
+ ].join("\n"),
412
+ 'Usage: /rule add {"id":"big-whales","kinds":["whale_trade"],"minUsd":25000}'
413
+ );
414
+ }
415
+ let parsed;
416
+ try {
417
+ parsed = JSON.parse(json);
418
+ } catch {
419
+ return reply('That is not valid JSON. Example: /rule add {"id":"whales","kinds":["whale_trade"],"minUsd":5000}');
420
+ }
421
+ const result = safeParseRule(parsed);
422
+ if (!result.success) return reply(`Rule rejected: ${formatValidationError(result.error)}`);
423
+ const rule = result.data;
424
+ if (subscription.rules.some((existing) => existing.id === rule.id)) {
425
+ return reply(`A rule with id "${rule.id}" already exists. Remove it first with /rule rm ${rule.id}.`);
426
+ }
427
+ const decision = await deps.gate.checkAddRule(context.subscriberId, rule, subscription.rules.length);
428
+ if (!decision.allowed) return reply(`Rule rejected: ${decision.reason}`);
429
+ subscription.rules.push(rule);
430
+ deps.store.saveSubscription(subscription);
431
+ return html(
432
+ `Rule added: <code>${escapeHtml(ruleSummary(rule))}</code>`,
433
+ `Rule added: ${ruleSummary(rule)}`
434
+ );
435
+ }
436
+ if (action === "rm" || action === "remove" || action === "delete") {
437
+ const id = context.args[1]?.trim();
438
+ if (!id) return reply("Usage: /rule rm <rule id>. List them with /rules.");
439
+ const index = subscription.rules.findIndex((rule) => rule.id === id);
440
+ if (index === -1) return reply(`No rule with id "${id}". List them with /rules.`);
441
+ subscription.rules.splice(index, 1);
442
+ deps.store.saveSubscription(subscription);
443
+ return reply(`Rule ${id} removed.`);
444
+ }
445
+ if (action === "on" || action === "off") {
446
+ const id = context.args[1]?.trim();
447
+ if (!id) return reply(`Usage: /rule ${action} <rule id>. List them with /rules.`);
448
+ const rule = subscription.rules.find((entry) => entry.id === id);
449
+ if (!rule) return reply(`No rule with id "${id}". List them with /rules.`);
450
+ rule.enabled = action === "on";
451
+ deps.store.saveSubscription(subscription);
452
+ return reply(`Rule ${id} is now ${rule.enabled ? "enabled" : "disabled"}.`);
453
+ }
454
+ return html(
455
+ [
456
+ "Usage:",
457
+ "\u2022 <code>/rule add {json}</code>",
458
+ "\u2022 <code>/rule rm &lt;id&gt;</code>",
459
+ "\u2022 <code>/rule on &lt;id&gt;</code>",
460
+ "\u2022 <code>/rule off &lt;id&gt;</code>"
461
+ ].join("\n"),
462
+ "Usage: /rule add {json} | /rule rm <id> | /rule on <id> | /rule off <id>"
463
+ );
464
+ }
465
+ async function handleThreshold(context) {
466
+ const subscription = firstSubscription(context);
467
+ if (!subscription) return reply("Subscribe first with /subscribe.");
468
+ const [ruleId, rawUsd] = context.args;
469
+ if (!ruleId || !rawUsd) {
470
+ return reply("Usage: /threshold <rule id> <usd>. Example: /threshold whales 5000");
471
+ }
472
+ const rule = subscription.rules.find((entry) => entry.id === ruleId);
473
+ if (!rule) return reply(`No rule with id "${ruleId}". List them with /rules.`);
474
+ const usd = Number(rawUsd);
475
+ if (!Number.isFinite(usd) || usd < 0) {
476
+ return reply(`"${rawUsd}" is not a valid amount. Use a positive number, for example /threshold ${ruleId} 5000`);
477
+ }
478
+ if (rule.maxUsd !== void 0 && usd > rule.maxUsd) {
479
+ return reply(`minUsd ${usd} would exceed this rule's maxUsd ${rule.maxUsd}. Lower it or raise maxUsd first.`);
480
+ }
481
+ rule.minUsd = usd;
482
+ const decision = await deps.gate.checkRule(context.subscriberId, rule);
483
+ if (!decision.allowed) return reply(`Change rejected: ${decision.reason}`);
484
+ deps.store.saveSubscription(subscription);
485
+ return reply(`Rule ${ruleId} now requires at least $${usd} of value.`);
486
+ }
487
+ async function handleWatch(context, add) {
488
+ const subscription = firstSubscription(context);
489
+ if (!subscription) return reply("Subscribe first with /subscribe.");
490
+ const rawToken = context.args[0]?.trim();
491
+ if (!rawToken) {
492
+ return reply(`Usage: /${add ? "watch" : "unwatch"} <token address> [rule id]`);
493
+ }
494
+ if (!/^0x[0-9a-fA-F]{40}$/.test(rawToken)) {
495
+ return reply(`"${rawToken}" is not a token address. Pass the 0x contract address from the alert.`);
496
+ }
497
+ const token = (0, import_viem3.getAddress)(rawToken).toLowerCase();
498
+ const ruleId = context.args[1]?.trim();
499
+ const rule = ruleId ? subscription.rules.find((entry) => entry.id === ruleId) : subscription.rules[0];
500
+ if (!rule) {
501
+ return reply(
502
+ ruleId ? `No rule with id "${ruleId}". List them with /rules.` : "This subscription has no rules yet. Add one with /rule add."
503
+ );
504
+ }
505
+ if (add) {
506
+ if (rule.tokens.includes(token)) return reply(`${rawToken} is already on rule ${rule.id}.`);
507
+ rule.tokens.push(token);
508
+ const decision = await deps.gate.checkRule(context.subscriberId, rule);
509
+ if (!decision.allowed) {
510
+ rule.tokens.pop();
511
+ return reply(`Cannot add: ${decision.reason}`);
512
+ }
513
+ deps.store.saveSubscription(subscription);
514
+ return reply(`Watching ${rawToken} on rule ${rule.id} (${rule.tokens.length} tokens).`);
515
+ }
516
+ const index = rule.tokens.indexOf(token);
517
+ if (index === -1) return reply(`${rawToken} is not on rule ${rule.id}.`);
518
+ rule.tokens.splice(index, 1);
519
+ deps.store.saveSubscription(subscription);
520
+ return reply(`Stopped watching ${rawToken} on rule ${rule.id}.`);
521
+ }
522
+ async function handleTier(context) {
523
+ const { entitlement, policy } = await deps.gate.resolve(context.subscriberId);
524
+ const expiry = entitlement.expiresAtMs === null ? "no expiry" : `expires ${new Date(entitlement.expiresAtMs).toISOString()}`;
525
+ const lines = [
526
+ `<b>Tier: ${escapeHtml(policy.tier)}</b> (${escapeHtml(expiry)}, source ${escapeHtml(entitlement.source)})`,
527
+ `Subscriptions: up to ${policy.maxSubscriptions}`,
528
+ `Rules per subscription: up to ${policy.maxRulesPerSubscription}`,
529
+ `Filters per rule: up to ${policy.maxRuleComplexity}`,
530
+ `Watchlist size: up to ${policy.maxWatchlistTokens}`,
531
+ `Alerts per hour: up to ${policy.maxAlertsPerHour}`,
532
+ `Delivery delay: ${policy.deliveryDelayMs / 1e3}s`,
533
+ `Liquidity and reputation filters: ${policy.allowOnChainLookupFilters ? "included" : "premium only"}`,
534
+ `Event kinds: ${policy.allowedKinds.join(", ")}`
535
+ ];
536
+ return html(lines.join("\n"), lines.join("\n").replace(/<[^>]+>/g, ""));
537
+ }
538
+ async function handleStatus(context) {
539
+ const subs = subscriptionsOf(context);
540
+ if (subs.length === 0) return reply("No subscriptions. Use /subscribe to start one.");
541
+ const delivered = deps.store.countDeliveriesForSubscriber(context.subscriberId, now() - 36e5);
542
+ const { policy } = await deps.gate.resolve(context.subscriberId);
543
+ const lines = [
544
+ `<b>${subs.length} subscription${subs.length === 1 ? "" : "s"}</b>, ${delivered} alerts in the last hour (cap ${policy.maxAlertsPerHour})`,
545
+ ...subs.map(
546
+ (sub) => `\u2022 <code>${escapeHtml(sub.id)}</code> \u2192 ${escapeHtml(sub.target)} (${sub.rules.length} rules, ${sub.enabled ? "enabled" : "disabled"})`
547
+ )
548
+ ];
549
+ return html(lines.join("\n"), lines.join("\n").replace(/<[^>]+>/g, ""));
550
+ }
551
+ async function handleLink(context) {
552
+ const [address, signature] = context.args;
553
+ if (!address) {
554
+ const nonce2 = deps.store.issueLinkNonce(context.subscriberId, newId());
555
+ const message = linkMessage(context.subscriberId, nonce2);
556
+ return html(
557
+ [
558
+ "Sign this exact message with the wallet that pays, then send",
559
+ `<code>/link &lt;address&gt; &lt;signature&gt;</code>`,
560
+ "",
561
+ `<pre>${escapeHtml(message)}</pre>`
562
+ ].join("\n"),
563
+ `Sign this message and reply with /link <address> <signature>:
564
+ ${message}`
565
+ );
566
+ }
567
+ if (!signature) {
568
+ return reply("Usage: /link <address> <signature>. Send /link on its own to get the message to sign.");
569
+ }
570
+ const nonce = deps.store.getLinkNonce(context.subscriberId);
571
+ if (!nonce) {
572
+ return reply("No pending link request. Send /link on its own first to get a fresh message to sign.");
573
+ }
574
+ const verified = await verifyWalletLink(context.subscriberId, nonce, address, signature);
575
+ if (!verified) {
576
+ return reply("That signature does not match. Sign the exact message from /link with the wallet you named.");
577
+ }
578
+ deps.store.linkWallet(context.subscriberId, verified);
579
+ deps.store.clearLinkNonce(context.subscriberId);
580
+ return reply(`Wallet ${verified} linked. Payments from it now count towards your subscription.`);
581
+ }
582
+ function handleUpgrade() {
583
+ if (!deps.upgradeInstructions) {
584
+ return reply(
585
+ "This deployment does not sell premium access. The operator grants it through configuration; ask them."
586
+ );
587
+ }
588
+ return html(escapeHtml(deps.upgradeInstructions), deps.upgradeInstructions);
589
+ }
590
+ function handleHelp() {
591
+ const header = "hood-alerts: Robinhood Chain memecoin alerts.";
592
+ const kinds = "Event kinds: launch, curve_trade, graduation, whale_trade.";
593
+ const lifecycle = "NOXA lists instantly (no bonding curve), so it never produces curve_trade or graduation. The Odyssey produces all four.";
594
+ return html(
595
+ [
596
+ `<b>${escapeHtml(header)}</b>`,
597
+ "",
598
+ ...COMMANDS.map(
599
+ (command) => `<code>${escapeHtml(command.usage)}</code> - ${escapeHtml(command.description)}`
600
+ ),
601
+ "",
602
+ escapeHtml(kinds),
603
+ escapeHtml(lifecycle)
604
+ ].join("\n"),
605
+ [
606
+ header,
607
+ "",
608
+ ...COMMANDS.map((command) => `${command.usage} - ${command.description}`),
609
+ "",
610
+ kinds,
611
+ lifecycle
612
+ ].join("\n")
613
+ );
614
+ }
615
+ return {
616
+ commands: COMMANDS,
617
+ async handle(command, context) {
618
+ const name = command.replace(/^\//, "").split("@")[0]?.toLowerCase() ?? "";
619
+ try {
620
+ switch (name) {
621
+ case "start":
622
+ case "help":
623
+ return handleHelp();
624
+ case "subscribe":
625
+ return await handleSubscribe(context);
626
+ case "unsubscribe":
627
+ return await handleUnsubscribe(context);
628
+ case "rules":
629
+ return handleRules(context);
630
+ case "rule":
631
+ return await handleRule(context);
632
+ case "threshold":
633
+ return await handleThreshold(context);
634
+ case "watch":
635
+ return await handleWatch(context, true);
636
+ case "unwatch":
637
+ return await handleWatch(context, false);
638
+ case "tier":
639
+ return await handleTier(context);
640
+ case "status":
641
+ return await handleStatus(context);
642
+ case "link":
643
+ return await handleLink(context);
644
+ case "upgrade":
645
+ return handleUpgrade();
646
+ default:
647
+ return reply(`Unknown command "${name}". Send /help for the list.`);
648
+ }
649
+ } catch (error) {
650
+ const message = error instanceof Error ? error.message : String(error);
651
+ return reply(`That command failed: ${message}. Try /help, or report this if it keeps happening.`);
652
+ }
653
+ }
654
+ };
655
+ }
656
+
657
+ // src/notifiers/http.ts
658
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
659
+ function resolveFetch(injected) {
660
+ if (injected) return injected;
661
+ if (typeof globalThis.fetch !== "function") {
662
+ throw new Error("hood-alerts: global fetch is unavailable (Node 20+ required), and none was injected");
663
+ }
664
+ return globalThis.fetch;
665
+ }
666
+ async function postJson(fetchImpl, url, payload, headers = {}) {
667
+ const response = await fetchImpl(url, {
668
+ method: "POST",
669
+ headers: { "content-type": "application/json", ...headers },
670
+ body: JSON.stringify(payload)
671
+ });
672
+ const raw = await response.text();
673
+ let body = null;
674
+ if (raw) {
675
+ try {
676
+ body = JSON.parse(raw);
677
+ } catch {
678
+ body = null;
679
+ }
680
+ }
681
+ return { status: response.status, ok: response.ok, headers: response.headers, body, raw };
682
+ }
683
+ function numericHeader(headers, name) {
684
+ const value = headers.get(name);
685
+ if (value === null) return null;
686
+ const parsed = Number(value);
687
+ return Number.isFinite(parsed) ? parsed : null;
688
+ }
689
+ function pick(body, ...path) {
690
+ let current = body;
691
+ for (const key of path) {
692
+ if (typeof current !== "object" || current === null) return void 0;
693
+ current = current[key];
694
+ }
695
+ return current;
696
+ }
697
+
698
+ // src/notifiers/telegram.ts
699
+ var PERMANENT_STATUSES = /* @__PURE__ */ new Set([400, 401, 403, 404]);
700
+ function createTelegramClient(options) {
701
+ if (!/^\d+:[A-Za-z0-9_-]+$/.test(options.botToken)) {
702
+ throw new Error('hood-alerts: TELEGRAM_BOT_TOKEN must look like "<bot id>:<secret>"');
703
+ }
704
+ const fetchImpl = resolveFetch(options.fetch);
705
+ const apiBase = (options.apiBase ?? "https://api.telegram.org").replace(/\/+$/, "");
706
+ const maxAttempts = options.maxAttempts ?? 4;
707
+ const backoffMs = options.backoffMs ?? 500;
708
+ const maxWaitMs = options.maxWaitMs ?? 6e4;
709
+ const sleep2 = options.sleep ?? sleep;
710
+ let lastAttempts = 0;
711
+ return {
712
+ get lastAttempts() {
713
+ return lastAttempts;
714
+ },
715
+ async call(method, params) {
716
+ const url = `${apiBase}/bot${options.botToken}/${method}`;
717
+ let attempt = 0;
718
+ let last = {
719
+ ok: false,
720
+ status: 0,
721
+ result: null,
722
+ description: "no attempt was made",
723
+ retryAfterSeconds: null
724
+ };
725
+ while (attempt < maxAttempts) {
726
+ attempt += 1;
727
+ lastAttempts = attempt;
728
+ let response;
729
+ try {
730
+ response = await postJson(fetchImpl, url, params);
731
+ } catch (error) {
732
+ last = {
733
+ ok: false,
734
+ status: 0,
735
+ result: null,
736
+ description: error instanceof Error ? error.message : String(error),
737
+ retryAfterSeconds: null
738
+ };
739
+ if (attempt >= maxAttempts) break;
740
+ await sleep2(backoffMs * 2 ** (attempt - 1));
741
+ continue;
742
+ }
743
+ const description = typeof pick(response.body, "description") === "string" ? pick(response.body, "description") : null;
744
+ const retryAfterField = pick(response.body, "parameters", "retry_after");
745
+ const retryAfterSeconds = typeof retryAfterField === "number" ? retryAfterField : numericHeader(response.headers, "retry-after");
746
+ last = {
747
+ ok: response.status === 200 && pick(response.body, "ok") === true,
748
+ status: response.status,
749
+ result: pick(response.body, "result") ?? null,
750
+ description,
751
+ retryAfterSeconds
752
+ };
753
+ if (last.ok) return last;
754
+ if (response.status === 429) {
755
+ const waitMs = (retryAfterSeconds ?? backoffMs / 1e3) * 1e3;
756
+ if (waitMs > maxWaitMs || attempt >= maxAttempts) return last;
757
+ await sleep2(waitMs);
758
+ continue;
759
+ }
760
+ if (PERMANENT_STATUSES.has(response.status)) return last;
761
+ if (attempt >= maxAttempts) return last;
762
+ await sleep2(backoffMs * 2 ** (attempt - 1));
763
+ }
764
+ return last;
765
+ }
766
+ };
767
+ }
768
+
769
+ // src/service/logger.ts
770
+ function createSilentLogger() {
771
+ const silent = {
772
+ debug: () => void 0,
773
+ info: () => void 0,
774
+ warn: () => void 0,
775
+ error: () => void 0,
776
+ child: () => silent
777
+ };
778
+ return silent;
779
+ }
780
+
781
+ // src/bot/telegram-bot.ts
782
+ function parseCommand(text) {
783
+ const trimmed = text.trim();
784
+ if (!trimmed.startsWith("/")) return null;
785
+ const [head, ...tail] = trimmed.split(/\s+/);
786
+ if (!head) return null;
787
+ return { command: head, args: tail, rest: trimmed.slice(head.length).trim() };
788
+ }
789
+ function createTelegramBot(options) {
790
+ const client = options.client ?? createTelegramClient(options);
791
+ const logger = options.logger ?? createSilentLogger();
792
+ const pollTimeout = options.pollTimeoutSeconds ?? 30;
793
+ let offset = options.offsetStore?.get() ?? 0;
794
+ let running = false;
795
+ async function handleUpdate(update) {
796
+ const message = update.message;
797
+ const text = message?.text;
798
+ const chatId = message?.chat?.id;
799
+ const fromId = message?.from?.id;
800
+ if (!text || chatId === void 0 || fromId === void 0 || message?.from?.is_bot === true) return;
801
+ const parsed = parseCommand(text);
802
+ if (!parsed) return;
803
+ const context = {
804
+ subscriberId: `telegram:${fromId}`,
805
+ platform: "telegram",
806
+ defaultTarget: String(chatId),
807
+ args: parsed.args,
808
+ rest: parsed.rest
809
+ };
810
+ const reply2 = await options.router.handle(parsed.command, context);
811
+ const response = await client.call("sendMessage", {
812
+ chat_id: chatId,
813
+ text: reply2.html,
814
+ parse_mode: "HTML",
815
+ link_preview_options: { is_disabled: true }
816
+ });
817
+ if (!response.ok) {
818
+ logger.warn("telegram reply failed", {
819
+ chatId,
820
+ command: parsed.command,
821
+ error: response.description
822
+ });
823
+ }
824
+ }
825
+ return {
826
+ async publishCommands() {
827
+ const response = await client.call("setMyCommands", {
828
+ commands: options.router.commands.map((command) => ({
829
+ command: command.name,
830
+ description: command.description.slice(0, 256)
831
+ }))
832
+ });
833
+ if (!response.ok) logger.warn("setMyCommands failed", { error: response.description });
834
+ return response.ok;
835
+ },
836
+ async pollOnce() {
837
+ const response = await client.call("getUpdates", {
838
+ ...offset > 0 ? { offset } : {},
839
+ timeout: pollTimeout,
840
+ allowed_updates: ["message"]
841
+ });
842
+ if (!response.ok) {
843
+ logger.warn("getUpdates failed", { error: response.description });
844
+ return 0;
845
+ }
846
+ const updates = Array.isArray(response.result) ? response.result : [];
847
+ for (const update of updates) {
848
+ try {
849
+ await handleUpdate(update);
850
+ } catch (error) {
851
+ logger.error("telegram update handler threw", {
852
+ updateId: update.update_id,
853
+ error: error instanceof Error ? error.message : String(error)
854
+ });
855
+ }
856
+ offset = update.update_id + 1;
857
+ options.offsetStore?.set(offset);
858
+ }
859
+ return updates.length;
860
+ },
861
+ async start() {
862
+ running = true;
863
+ logger.info("telegram bot started", { offset });
864
+ while (running) {
865
+ try {
866
+ await this.pollOnce();
867
+ } catch (error) {
868
+ logger.error("telegram poll failed", {
869
+ error: error instanceof Error ? error.message : String(error)
870
+ });
871
+ await new Promise((resolve) => setTimeout(resolve, 2e3));
872
+ }
873
+ }
874
+ logger.info("telegram bot stopped");
875
+ },
876
+ stop() {
877
+ running = false;
878
+ }
879
+ };
880
+ }
881
+
882
+ // src/bot/discord-bot.ts
883
+ var import_node_crypto2 = require("crypto");
884
+ var INTERACTION_TYPE = { PING: 1, APPLICATION_COMMAND: 2 };
885
+ var CALLBACK_TYPE = { PONG: 1, CHANNEL_MESSAGE_WITH_SOURCE: 4 };
886
+ var EPHEMERAL_FLAG = 1 << 6;
887
+ var ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
888
+ function verifyDiscordSignature(publicKey, signature, timestamp, rawBody) {
889
+ if (!/^[0-9a-fA-F]{64}$/.test(publicKey)) return false;
890
+ if (!/^[0-9a-fA-F]{128}$/.test(signature)) return false;
891
+ if (!timestamp) return false;
892
+ try {
893
+ const key = (0, import_node_crypto2.createPublicKey)({
894
+ key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(publicKey, "hex")]),
895
+ format: "der",
896
+ type: "spki"
897
+ });
898
+ return (0, import_node_crypto2.verify)(
899
+ null,
900
+ Buffer.from(timestamp + rawBody, "utf8"),
901
+ key,
902
+ Buffer.from(signature, "hex")
903
+ );
904
+ } catch {
905
+ return false;
906
+ }
907
+ }
908
+ function discordCommandDefinitions(commands) {
909
+ return commands.map((command) => ({
910
+ name: command.name,
911
+ // Discord caps descriptions at 100 characters and rejects the whole
912
+ // registration if any one is longer.
913
+ description: truncate(command.description, 100),
914
+ ...command.options && command.options.length > 0 ? {
915
+ options: command.options.map((option) => ({
916
+ type: 3,
917
+ name: option.name,
918
+ description: truncate(option.description, 100),
919
+ required: option.required
920
+ }))
921
+ } : {}
922
+ }));
923
+ }
924
+ async function registerDiscordCommands(commands, options) {
925
+ const fetchImpl = resolveFetch(options.fetch);
926
+ const apiBase = (options.apiBase ?? "https://discord.com/api/v10").replace(/\/+$/, "");
927
+ const url = options.guildId ? `${apiBase}/applications/${options.applicationId}/guilds/${options.guildId}/commands` : `${apiBase}/applications/${options.applicationId}/commands`;
928
+ const response = await fetchImpl(url, {
929
+ method: "PUT",
930
+ headers: {
931
+ "content-type": "application/json",
932
+ authorization: `Bot ${options.botToken}`
933
+ },
934
+ body: JSON.stringify(discordCommandDefinitions(commands))
935
+ });
936
+ const raw = await response.text();
937
+ if (!response.ok) {
938
+ throw new Error(`hood-alerts: Discord rejected the command registration (${response.status}): ${raw}`);
939
+ }
940
+ try {
941
+ const parsed = JSON.parse(raw);
942
+ return Array.isArray(parsed) ? parsed.length : 0;
943
+ } catch {
944
+ return 0;
945
+ }
946
+ }
947
+ function createDiscordInteractionHandler(options) {
948
+ const ephemeral = options.ephemeral ?? true;
949
+ return {
950
+ async handle(request) {
951
+ if (!request.signature || !request.timestamp || !verifyDiscordSignature(options.publicKey, request.signature, request.timestamp, request.rawBody)) {
952
+ return { status: 401, body: { error: "invalid request signature" } };
953
+ }
954
+ let interaction;
955
+ try {
956
+ interaction = JSON.parse(request.rawBody);
957
+ } catch {
958
+ return { status: 400, body: { error: "body is not JSON" } };
959
+ }
960
+ if (interaction.type === INTERACTION_TYPE.PING) {
961
+ return { status: 200, body: { type: CALLBACK_TYPE.PONG } };
962
+ }
963
+ if (interaction.type !== INTERACTION_TYPE.APPLICATION_COMMAND) {
964
+ return { status: 400, body: { error: `unsupported interaction type ${interaction.type}` } };
965
+ }
966
+ const name = interaction.data?.name;
967
+ if (!name) return { status: 400, body: { error: "interaction carried no command name" } };
968
+ const values = (interaction.data?.options ?? []).map((option) => option.value === void 0 ? "" : String(option.value)).filter((value) => value.length > 0);
969
+ const rest = values.join(" ");
970
+ const args = values.length === 1 ? values[0].split(/\s+/) : values;
971
+ const userId = interaction.member?.user?.id ?? interaction.user?.id ?? "";
972
+ const context = {
973
+ // A guild pays once for all its channels; a DM is billed to the user.
974
+ subscriberId: `discord:${interaction.guild_id ?? userId}`,
975
+ platform: "discord",
976
+ defaultTarget: interaction.channel_id ?? "",
977
+ args,
978
+ rest: `${name} ${rest}`.trim()
979
+ };
980
+ const reply2 = await options.router.handle(name, context);
981
+ return {
982
+ status: 200,
983
+ body: {
984
+ type: CALLBACK_TYPE.CHANNEL_MESSAGE_WITH_SOURCE,
985
+ data: {
986
+ // Discord hard-caps message content at 2000 characters.
987
+ content: truncate(reply2.text, 1900),
988
+ ...ephemeral ? { flags: EPHEMERAL_FLAG } : {}
989
+ }
990
+ }
991
+ };
992
+ }
993
+ };
994
+ }
995
+ // Annotate the CommonJS export names for ESM import in node:
996
+ 0 && (module.exports = {
997
+ CALLBACK_TYPE,
998
+ COMMANDS,
999
+ EPHEMERAL_FLAG,
1000
+ INTERACTION_TYPE,
1001
+ createCommandRouter,
1002
+ createDiscordInteractionHandler,
1003
+ createTelegramBot,
1004
+ discordCommandDefinitions,
1005
+ registerDiscordCommands,
1006
+ verifyDiscordSignature
1007
+ });
1008
+ //# sourceMappingURL=index.cjs.map