responses-proxy 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -0
- package/cli.js +118 -0
- package/dist/anthropic-messages.js +383 -0
- package/dist/anthropic-messages.test.js +209 -0
- package/dist/audit-log.js +138 -0
- package/dist/audit-log.test.js +480 -0
- package/dist/billing-expiration.js +70 -0
- package/dist/billing-expiration.test.js +114 -0
- package/dist/billing.js +716 -0
- package/dist/billing.test.js +228 -0
- package/dist/chatgpt-oauth-store.js +240 -0
- package/dist/chatgpt-oauth-store.test.js +88 -0
- package/dist/chatgpt-oauth.js +118 -0
- package/dist/chatgpt-oauth.test.js +63 -0
- package/dist/chatgpt-provider-auth.js +60 -0
- package/dist/chatgpt-provider-auth.test.js +101 -0
- package/dist/client/app-icon.svg +17 -0
- package/dist/client/assets/index-C7Vvhst8.js +14 -0
- package/dist/client/assets/index-DpqgYK3L.css +1 -0
- package/dist/client/favicon.svg +17 -0
- package/dist/client/index.html +31 -0
- package/dist/client-config-apply.js +345 -0
- package/dist/client-config-apply.test.js +185 -0
- package/dist/client-token-limits.js +111 -0
- package/dist/client-token-limits.test.js +129 -0
- package/dist/codex-config.js +47 -0
- package/dist/codex-setup.js +87 -0
- package/dist/codex-setup.test.js +30 -0
- package/dist/config.js +314 -0
- package/dist/cost-analytics.js +31 -0
- package/dist/cost-analytics.test.js +38 -0
- package/dist/customer-key-access.js +126 -0
- package/dist/customer-key-access.test.js +178 -0
- package/dist/customer-keys.js +209 -0
- package/dist/customer-keys.test.js +68 -0
- package/dist/customer-usage.js +18 -0
- package/dist/customer-usage.test.js +55 -0
- package/dist/dashboard-auth.js +318 -0
- package/dist/dashboard-auth.test.js +133 -0
- package/dist/dashboard-serving.test.js +235 -0
- package/dist/error-response.js +174 -0
- package/dist/error-response.test.js +88 -0
- package/dist/forward.js +357 -0
- package/dist/health-websocket-manager.js +174 -0
- package/dist/http-rate-limit.js +36 -0
- package/dist/http-rate-limit.test.js +62 -0
- package/dist/kiro-auth.js +136 -0
- package/dist/kiro-auth.test.js +234 -0
- package/dist/kiro-codewhisperer.js +646 -0
- package/dist/kiro-codewhisperer.test.js +219 -0
- package/dist/kiro-device-login.js +338 -0
- package/dist/kiro-eventstream.js +219 -0
- package/dist/kiro-eventstream.test.js +79 -0
- package/dist/kiro-forward.js +401 -0
- package/dist/kiro-import-cli.js +69 -0
- package/dist/kiro-import.js +94 -0
- package/dist/kiro-import.test.js +125 -0
- package/dist/kiro-token-store.js +196 -0
- package/dist/kiro-token-store.test.js +207 -0
- package/dist/krouter-usage.js +243 -0
- package/dist/model-combo-repository.js +147 -0
- package/dist/model-routing.js +69 -0
- package/dist/model-routing.test.js +41 -0
- package/dist/normalize-request.js +531 -0
- package/dist/normalize-request.test.js +277 -0
- package/dist/omv-public-firewall.test.js +11 -0
- package/dist/package.json +17 -0
- package/dist/prompt-cache-state.js +146 -0
- package/dist/prompt-cache-state.test.js +71 -0
- package/dist/prompt-cache.js +229 -0
- package/dist/provider-health-service.js +404 -0
- package/dist/provider-request-parameters.js +107 -0
- package/dist/provider-request-parameters.test.js +26 -0
- package/dist/provider-routing.js +114 -0
- package/dist/provider-routing.test.js +64 -0
- package/dist/provider-usage.js +314 -0
- package/dist/request-timeout-policy.js +61 -0
- package/dist/request-timeout-policy.test.js +40 -0
- package/dist/response-cache.js +69 -0
- package/dist/response-cache.test.js +28 -0
- package/dist/routing-combo-repository.js +300 -0
- package/dist/routing-engine.js +377 -0
- package/dist/routing-integration.js +155 -0
- package/dist/routing-simulation-engine.js +326 -0
- package/dist/rtk-layer.js +483 -0
- package/dist/rtk-layer.test.js +198 -0
- package/dist/runtime-provider-repository.js +1742 -0
- package/dist/runtime-provider-repository.test.js +1177 -0
- package/dist/schema.js +118 -0
- package/dist/schema.test.js +16 -0
- package/dist/sepay-webhook.js +87 -0
- package/dist/sepay-webhook.test.js +142 -0
- package/dist/server-body-limit.test.js +35 -0
- package/dist/server-client-token-limits.test.js +161 -0
- package/dist/server-codex-config-setup.test.js +76 -0
- package/dist/server-http-rate-limit.test.js +80 -0
- package/dist/server-response-cache.test.js +105 -0
- package/dist/server-routes-alias.test.js +39 -0
- package/dist/server-sepay-webhook-security.test.js +59 -0
- package/dist/server.js +5906 -0
- package/dist/session-log.js +178 -0
- package/dist/tailnet-funnel-script.test.js +33 -0
- package/dist/telegram-bot/actions.js +118 -0
- package/dist/telegram-bot/admin-actions.js +103 -0
- package/dist/telegram-bot/auth.js +46 -0
- package/dist/telegram-bot/auth.test.js +1 -0
- package/dist/telegram-bot/bot-identity-repository.js +189 -0
- package/dist/telegram-bot/bot-identity-repository.test.js +78 -0
- package/dist/telegram-bot/callbacks.js +30 -0
- package/dist/telegram-bot/codex-config-delivery.js +38 -0
- package/dist/telegram-bot/codex-config-delivery.test.js +75 -0
- package/dist/telegram-bot/commands/accounts.js +140 -0
- package/dist/telegram-bot/commands/apikey.js +737 -0
- package/dist/telegram-bot/commands/apply.js +265 -0
- package/dist/telegram-bot/commands/clients.js +13 -0
- package/dist/telegram-bot/commands/customer-billing.test.js +271 -0
- package/dist/telegram-bot/commands/grant.js +138 -0
- package/dist/telegram-bot/commands/grant.test.js +217 -0
- package/dist/telegram-bot/commands/help.js +52 -0
- package/dist/telegram-bot/commands/me.js +53 -0
- package/dist/telegram-bot/commands/models.js +6 -0
- package/dist/telegram-bot/commands/oauth.js +64 -0
- package/dist/telegram-bot/commands/plans.js +96 -0
- package/dist/telegram-bot/commands/providers.js +27 -0
- package/dist/telegram-bot/commands/quota.js +10 -0
- package/dist/telegram-bot/commands/renew-user.js +139 -0
- package/dist/telegram-bot/commands/renew-user.test.js +184 -0
- package/dist/telegram-bot/commands/renew.js +1369 -0
- package/dist/telegram-bot/commands/renew.test.js +1633 -0
- package/dist/telegram-bot/commands/start.js +212 -0
- package/dist/telegram-bot/commands/start.test.js +280 -0
- package/dist/telegram-bot/commands/status.js +6 -0
- package/dist/telegram-bot/commands/tailscale.js +15 -0
- package/dist/telegram-bot/commands/tailscale.test.js +76 -0
- package/dist/telegram-bot/commands/test.js +51 -0
- package/dist/telegram-bot/commands/test.test.js +14 -0
- package/dist/telegram-bot/commands/usage.js +10 -0
- package/dist/telegram-bot/config.js +98 -0
- package/dist/telegram-bot/config.test.js +42 -0
- package/dist/telegram-bot/customer-actions.js +160 -0
- package/dist/telegram-bot/customer-api-keys.js +68 -0
- package/dist/telegram-bot/customer-billing.js +72 -0
- package/dist/telegram-bot/customer-workspace-repository.js +134 -0
- package/dist/telegram-bot/customer-workspace-repository.test.js +47 -0
- package/dist/telegram-bot/dashboard-login.js +39 -0
- package/dist/telegram-bot/format.js +140 -0
- package/dist/telegram-bot/grants.js +370 -0
- package/dist/telegram-bot/grants.test.js +290 -0
- package/dist/telegram-bot/index.js +85 -0
- package/dist/telegram-bot/message-cleanup.js +55 -0
- package/dist/telegram-bot/message-cleanup.test.js +77 -0
- package/dist/telegram-bot/message-format.js +45 -0
- package/dist/telegram-bot/message-format.test.js +10 -0
- package/dist/telegram-bot/proxy-client.js +174 -0
- package/dist/telegram-bot/rate-limit.js +95 -0
- package/dist/telegram-bot/rate-limit.test.js +58 -0
- package/dist/telegram-bot/sessions.js +171 -0
- package/dist/telegram-bot/sessions.test.js +107 -0
- package/dist/telegram-bot/telegram-adapter.js +126 -0
- package/dist/telegram-bot/worker.js +63 -0
- package/package.json +39 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { extractResponseText } from "./format.js";
|
|
2
|
+
export class ProxyClientError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
body;
|
|
5
|
+
constructor(message, status, body) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.body = body;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export class ResponsesProxyClient {
|
|
12
|
+
baseUrl;
|
|
13
|
+
clientApiKey;
|
|
14
|
+
requestTimeoutMs;
|
|
15
|
+
constructor(baseUrl, clientApiKey, requestTimeoutMs = 30_000) {
|
|
16
|
+
this.baseUrl = baseUrl;
|
|
17
|
+
this.clientApiKey = clientApiKey;
|
|
18
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
19
|
+
}
|
|
20
|
+
async getHealth() {
|
|
21
|
+
return this.requestJson("/health");
|
|
22
|
+
}
|
|
23
|
+
async getProviders() {
|
|
24
|
+
return this.requestJson("/api/providers");
|
|
25
|
+
}
|
|
26
|
+
async getUsageStats() {
|
|
27
|
+
return this.requestJson("/api/stats/usage");
|
|
28
|
+
}
|
|
29
|
+
async getLatestPromptCache() {
|
|
30
|
+
return this.requestJson("/api/debug/prompt-cache/latest");
|
|
31
|
+
}
|
|
32
|
+
async getClientConfigs() {
|
|
33
|
+
return this.requestJson("/api/client-configs/status");
|
|
34
|
+
}
|
|
35
|
+
async setClientRouteApiKeys(input) {
|
|
36
|
+
return this.requestJson("/api/client-route-keys", {
|
|
37
|
+
method: "POST",
|
|
38
|
+
body: input,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
async getProviderDetails(providerId) {
|
|
42
|
+
return this.requestJson(`/api/providers/${encodeURIComponent(providerId)}`);
|
|
43
|
+
}
|
|
44
|
+
async getProviderModels(providerId) {
|
|
45
|
+
return this.requestJson(`/api/provider-models?providerId=${encodeURIComponent(providerId)}`);
|
|
46
|
+
}
|
|
47
|
+
async setProviderRoute(input) {
|
|
48
|
+
return this.requestJson("/api/provider-routes", {
|
|
49
|
+
method: "POST",
|
|
50
|
+
body: input,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
async applyClientConfig(input) {
|
|
54
|
+
return this.requestJson("/api/client-configs/apply", {
|
|
55
|
+
method: "POST",
|
|
56
|
+
body: {
|
|
57
|
+
client: input.client,
|
|
58
|
+
model: input.model,
|
|
59
|
+
routeApiKey: input.routeApiKey,
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
async getOauthStatus() {
|
|
64
|
+
return this.requestJson("/api/chatgpt-oauth/status");
|
|
65
|
+
}
|
|
66
|
+
async startOauth() {
|
|
67
|
+
return this.requestJson("/api/chatgpt-oauth/start", { method: "POST" });
|
|
68
|
+
}
|
|
69
|
+
async completeOauth(callbackUrl) {
|
|
70
|
+
return this.requestJson("/api/chatgpt-oauth/callback", {
|
|
71
|
+
method: "POST",
|
|
72
|
+
body: {
|
|
73
|
+
callbackUrl,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
async refreshAccount(accountId) {
|
|
78
|
+
return this.requestJson(`/api/account-auth/accounts/${encodeURIComponent(accountId)}/refresh`, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
async disableAccount(accountId) {
|
|
83
|
+
return this.requestJson(`/api/account-auth/accounts/${encodeURIComponent(accountId)}/disable`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
async enableAccount(accountId) {
|
|
88
|
+
return this.requestJson(`/api/account-auth/accounts/${encodeURIComponent(accountId)}/enable`, {
|
|
89
|
+
method: "POST",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
async deleteAccount(accountId) {
|
|
93
|
+
return this.requestJson(`/api/account-auth/accounts/${encodeURIComponent(accountId)}`, {
|
|
94
|
+
method: "DELETE",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
async sendTestPrompt(input) {
|
|
98
|
+
const body = await this.requestJson("/v1/responses", {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: this.clientApiKey
|
|
101
|
+
? {
|
|
102
|
+
Authorization: `Bearer ${this.clientApiKey}`,
|
|
103
|
+
}
|
|
104
|
+
: undefined,
|
|
105
|
+
body: {
|
|
106
|
+
model: input.model,
|
|
107
|
+
input: input.prompt,
|
|
108
|
+
metadata: input.providerId ? { provider_id: input.providerId } : undefined,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
return {
|
|
112
|
+
outputText: extractResponseText(body),
|
|
113
|
+
requestId: typeof body?._request_id === "string" ? body._request_id : null,
|
|
114
|
+
raw: body,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
async getModels() {
|
|
118
|
+
return this.requestJson("/v1/models", {
|
|
119
|
+
headers: this.clientApiKey
|
|
120
|
+
? {
|
|
121
|
+
Authorization: `Bearer ${this.clientApiKey}`,
|
|
122
|
+
}
|
|
123
|
+
: undefined,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
async requestJson(pathname, options) {
|
|
127
|
+
let response;
|
|
128
|
+
try {
|
|
129
|
+
response = await fetch(`${this.baseUrl}${pathname}`, {
|
|
130
|
+
method: options?.method ?? "GET",
|
|
131
|
+
headers: {
|
|
132
|
+
"content-type": "application/json",
|
|
133
|
+
...(this.clientApiKey
|
|
134
|
+
? {
|
|
135
|
+
Authorization: `Bearer ${this.clientApiKey}`,
|
|
136
|
+
}
|
|
137
|
+
: {}),
|
|
138
|
+
...(options?.headers ?? {}),
|
|
139
|
+
},
|
|
140
|
+
body: options?.body ? JSON.stringify(options.body) : undefined,
|
|
141
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (error instanceof Error &&
|
|
146
|
+
(error.name === "TimeoutError" || error.name === "AbortError")) {
|
|
147
|
+
throw new ProxyClientError("Proxy request timed out", 504, {
|
|
148
|
+
error: {
|
|
149
|
+
type: "proxy_error",
|
|
150
|
+
code: "BOT_PROXY_REQUEST_TIMEOUT",
|
|
151
|
+
message: `Proxy request timed out after ${this.requestTimeoutMs}ms`,
|
|
152
|
+
retryable: true,
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
throw new ProxyClientError(error instanceof Error ? error.message : "Could not reach responses-proxy", 502, {
|
|
157
|
+
error: {
|
|
158
|
+
type: "proxy_error",
|
|
159
|
+
code: "BOT_PROXY_REQUEST_FAILED",
|
|
160
|
+
message: error instanceof Error ? error.message : "Could not reach responses-proxy",
|
|
161
|
+
retryable: true,
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
166
|
+
const isJson = contentType.includes("application/json");
|
|
167
|
+
const body = isJson ? (await response.json()) : undefined;
|
|
168
|
+
if (!response.ok) {
|
|
169
|
+
const message = body?.error?.message || `Proxy request failed with status ${response.status}`;
|
|
170
|
+
throw new ProxyClientError(message, response.status, body);
|
|
171
|
+
}
|
|
172
|
+
return body;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import BetterSqlite3 from "better-sqlite3";
|
|
4
|
+
export class SqliteRateLimiter {
|
|
5
|
+
db;
|
|
6
|
+
windowMs;
|
|
7
|
+
maxRequests;
|
|
8
|
+
constructor(db, windowMs, maxRequests) {
|
|
9
|
+
this.db = db;
|
|
10
|
+
this.windowMs = windowMs;
|
|
11
|
+
this.maxRequests = maxRequests;
|
|
12
|
+
}
|
|
13
|
+
static create(dbFile, options) {
|
|
14
|
+
mkdirSync(path.dirname(dbFile), { recursive: true });
|
|
15
|
+
const db = new BetterSqlite3(dbFile);
|
|
16
|
+
ensureRateLimitSchema(db);
|
|
17
|
+
return new SqliteRateLimiter(db, options.windowMs, options.maxRequests);
|
|
18
|
+
}
|
|
19
|
+
consume(userId, nowMs = Date.now()) {
|
|
20
|
+
this.pruneExpired(nowMs);
|
|
21
|
+
const row = this.db
|
|
22
|
+
.prepare(`SELECT user_id, window_started_at, hit_count
|
|
23
|
+
FROM telegram_bot_rate_limits
|
|
24
|
+
WHERE user_id = ?`)
|
|
25
|
+
.get(userId);
|
|
26
|
+
if (!row || nowMs - row.window_started_at >= this.windowMs) {
|
|
27
|
+
this.db
|
|
28
|
+
.prepare(`INSERT INTO telegram_bot_rate_limits (user_id, window_started_at, hit_count)
|
|
29
|
+
VALUES (?, ?, 1)
|
|
30
|
+
ON CONFLICT(user_id) DO UPDATE SET
|
|
31
|
+
window_started_at = excluded.window_started_at,
|
|
32
|
+
hit_count = excluded.hit_count`)
|
|
33
|
+
.run(userId, nowMs);
|
|
34
|
+
return {
|
|
35
|
+
allowed: true,
|
|
36
|
+
remaining: Math.max(this.maxRequests - 1, 0),
|
|
37
|
+
retryAfterMs: 0,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (row.hit_count >= this.maxRequests) {
|
|
41
|
+
return {
|
|
42
|
+
allowed: false,
|
|
43
|
+
remaining: 0,
|
|
44
|
+
retryAfterMs: Math.max(this.windowMs - (nowMs - row.window_started_at), 0),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const nextCount = row.hit_count + 1;
|
|
48
|
+
this.db
|
|
49
|
+
.prepare("UPDATE telegram_bot_rate_limits SET hit_count = ? WHERE user_id = ?")
|
|
50
|
+
.run(nextCount, userId);
|
|
51
|
+
return {
|
|
52
|
+
allowed: true,
|
|
53
|
+
remaining: Math.max(this.maxRequests - nextCount, 0),
|
|
54
|
+
retryAfterMs: 0,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
pruneExpired(nowMs) {
|
|
58
|
+
this.db
|
|
59
|
+
.prepare("DELETE FROM telegram_bot_rate_limits WHERE (? - window_started_at) >= ?")
|
|
60
|
+
.run(nowMs, this.windowMs);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function createRateLimitMiddleware(rateLimiter) {
|
|
64
|
+
return async (ctx, next) => {
|
|
65
|
+
const userId = ctx.from?.id?.toString();
|
|
66
|
+
if (!userId) {
|
|
67
|
+
await next();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const result = rateLimiter.consume(userId);
|
|
71
|
+
if (result.allowed) {
|
|
72
|
+
await next();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const retryAfterSeconds = Math.max(Math.ceil(result.retryAfterMs / 1000), 1);
|
|
76
|
+
if (ctx.callbackQuery) {
|
|
77
|
+
await ctx.answerCallbackQuery({
|
|
78
|
+
text: `⏳ Too many requests. Try again in ${retryAfterSeconds}s.`,
|
|
79
|
+
show_alert: true,
|
|
80
|
+
});
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
await ctx.reply(`⏳ Too many requests
|
|
84
|
+
Please try again in ${retryAfterSeconds}s.`);
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function ensureRateLimitSchema(db) {
|
|
88
|
+
db.exec(`
|
|
89
|
+
CREATE TABLE IF NOT EXISTS telegram_bot_rate_limits (
|
|
90
|
+
user_id TEXT PRIMARY KEY,
|
|
91
|
+
window_started_at INTEGER NOT NULL,
|
|
92
|
+
hit_count INTEGER NOT NULL
|
|
93
|
+
);
|
|
94
|
+
`);
|
|
95
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { afterEach, test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { SqliteRateLimiter } from "./rate-limit.js";
|
|
7
|
+
const tempDirs = [];
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
while (tempDirs.length > 0) {
|
|
10
|
+
const directory = tempDirs.pop();
|
|
11
|
+
if (directory) {
|
|
12
|
+
rmSync(directory, { recursive: true, force: true });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
test("SqliteRateLimiter blocks requests above the per-user threshold", () => {
|
|
17
|
+
const directory = mkdtempSync(path.join(tmpdir(), "telegram-rate-limit-"));
|
|
18
|
+
tempDirs.push(directory);
|
|
19
|
+
const dbFile = path.join(directory, "bot.sqlite");
|
|
20
|
+
const limiter = SqliteRateLimiter.create(dbFile, {
|
|
21
|
+
windowMs: 1_000,
|
|
22
|
+
maxRequests: 2,
|
|
23
|
+
});
|
|
24
|
+
assert.equal(limiter.consume("user-1", 1_000).allowed, true);
|
|
25
|
+
assert.equal(limiter.consume("user-1", 1_100).allowed, true);
|
|
26
|
+
const denied = limiter.consume("user-1", 1_200);
|
|
27
|
+
assert.equal(denied.allowed, false);
|
|
28
|
+
assert.equal(denied.remaining, 0);
|
|
29
|
+
assert.ok(denied.retryAfterMs > 0);
|
|
30
|
+
});
|
|
31
|
+
test("SqliteRateLimiter resets after the configured window", () => {
|
|
32
|
+
const directory = mkdtempSync(path.join(tmpdir(), "telegram-rate-limit-"));
|
|
33
|
+
tempDirs.push(directory);
|
|
34
|
+
const dbFile = path.join(directory, "bot.sqlite");
|
|
35
|
+
const limiter = SqliteRateLimiter.create(dbFile, {
|
|
36
|
+
windowMs: 1_000,
|
|
37
|
+
maxRequests: 1,
|
|
38
|
+
});
|
|
39
|
+
assert.equal(limiter.consume("user-2", 5_000).allowed, true);
|
|
40
|
+
assert.equal(limiter.consume("user-2", 5_500).allowed, false);
|
|
41
|
+
assert.equal(limiter.consume("user-2", 6_001).allowed, true);
|
|
42
|
+
});
|
|
43
|
+
test("SqliteRateLimiter persists counters across recreation", () => {
|
|
44
|
+
const directory = mkdtempSync(path.join(tmpdir(), "telegram-rate-limit-"));
|
|
45
|
+
tempDirs.push(directory);
|
|
46
|
+
const dbFile = path.join(directory, "bot.sqlite");
|
|
47
|
+
const firstLimiter = SqliteRateLimiter.create(dbFile, {
|
|
48
|
+
windowMs: 1_000,
|
|
49
|
+
maxRequests: 2,
|
|
50
|
+
});
|
|
51
|
+
firstLimiter.consume("user-3", 100);
|
|
52
|
+
const secondLimiter = SqliteRateLimiter.create(dbFile, {
|
|
53
|
+
windowMs: 1_000,
|
|
54
|
+
maxRequests: 2,
|
|
55
|
+
});
|
|
56
|
+
assert.equal(secondLimiter.consume("user-3", 200).allowed, true);
|
|
57
|
+
assert.equal(secondLimiter.consume("user-3", 300).allowed, false);
|
|
58
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import BetterSqlite3 from "better-sqlite3";
|
|
5
|
+
export class SqliteSessionStore {
|
|
6
|
+
db;
|
|
7
|
+
ttlMs;
|
|
8
|
+
constructor(db, ttlMs) {
|
|
9
|
+
this.db = db;
|
|
10
|
+
this.ttlMs = ttlMs;
|
|
11
|
+
}
|
|
12
|
+
static create(dbFile, ttlMs) {
|
|
13
|
+
mkdirSync(path.dirname(dbFile), { recursive: true });
|
|
14
|
+
const db = new BetterSqlite3(dbFile);
|
|
15
|
+
ensureSessionSchema(db);
|
|
16
|
+
return new SqliteSessionStore(db, ttlMs);
|
|
17
|
+
}
|
|
18
|
+
get(sessionScope, nowMs = Date.now()) {
|
|
19
|
+
this.pruneExpired(nowMs);
|
|
20
|
+
const row = this.db
|
|
21
|
+
.prepare(`SELECT session_scope, session_json, expires_at
|
|
22
|
+
FROM telegram_bot_sessions
|
|
23
|
+
WHERE session_scope = ?`)
|
|
24
|
+
.get(sessionScope);
|
|
25
|
+
if (!row) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
if (row.expires_at <= nowMs) {
|
|
29
|
+
this.clear(sessionScope);
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
return parseSessionJson(row.session_json);
|
|
33
|
+
}
|
|
34
|
+
set(sessionScope, session, nowMs = Date.now()) {
|
|
35
|
+
this.pruneExpired(nowMs);
|
|
36
|
+
this.db
|
|
37
|
+
.prepare(`INSERT INTO telegram_bot_sessions (session_scope, session_json, expires_at)
|
|
38
|
+
VALUES (?, ?, ?)
|
|
39
|
+
ON CONFLICT(session_scope) DO UPDATE SET
|
|
40
|
+
session_json = excluded.session_json,
|
|
41
|
+
expires_at = excluded.expires_at`)
|
|
42
|
+
.run(sessionScope, JSON.stringify(session), nowMs + this.ttlMs);
|
|
43
|
+
}
|
|
44
|
+
clear(sessionScope) {
|
|
45
|
+
this.db.prepare("DELETE FROM telegram_bot_sessions WHERE session_scope = ?").run(sessionScope);
|
|
46
|
+
}
|
|
47
|
+
issueCallbackToken(payload, nowMs = Date.now(), ttlMs = this.ttlMs) {
|
|
48
|
+
this.pruneExpired(nowMs);
|
|
49
|
+
const token = randomBytes(9).toString("base64url");
|
|
50
|
+
this.db
|
|
51
|
+
.prepare(`INSERT INTO telegram_bot_callback_states (token, payload_json, expires_at)
|
|
52
|
+
VALUES (?, ?, ?)`)
|
|
53
|
+
.run(token, JSON.stringify(payload), nowMs + ttlMs);
|
|
54
|
+
return token;
|
|
55
|
+
}
|
|
56
|
+
readCallbackToken(token, nowMs = Date.now()) {
|
|
57
|
+
this.pruneExpired(nowMs);
|
|
58
|
+
const row = this.db
|
|
59
|
+
.prepare(`SELECT token, payload_json, expires_at
|
|
60
|
+
FROM telegram_bot_callback_states
|
|
61
|
+
WHERE token = ?`)
|
|
62
|
+
.get(token);
|
|
63
|
+
if (!row) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
if (row.expires_at <= nowMs) {
|
|
67
|
+
this.clearCallbackToken(token);
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
return parseCallbackPayloadJson(row.payload_json);
|
|
71
|
+
}
|
|
72
|
+
clearCallbackToken(token) {
|
|
73
|
+
this.db.prepare("DELETE FROM telegram_bot_callback_states WHERE token = ?").run(token);
|
|
74
|
+
}
|
|
75
|
+
pruneExpired(nowMs) {
|
|
76
|
+
this.db.prepare("DELETE FROM telegram_bot_sessions WHERE expires_at <= ?").run(nowMs);
|
|
77
|
+
this.db.prepare("DELETE FROM telegram_bot_callback_states WHERE expires_at <= ?").run(nowMs);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function ensureSessionSchema(db) {
|
|
81
|
+
db.exec(`
|
|
82
|
+
CREATE TABLE IF NOT EXISTS telegram_bot_sessions (
|
|
83
|
+
session_scope TEXT PRIMARY KEY,
|
|
84
|
+
session_json TEXT NOT NULL,
|
|
85
|
+
expires_at INTEGER NOT NULL
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
CREATE TABLE IF NOT EXISTS telegram_bot_callback_states (
|
|
89
|
+
token TEXT PRIMARY KEY,
|
|
90
|
+
payload_json TEXT NOT NULL,
|
|
91
|
+
expires_at INTEGER NOT NULL
|
|
92
|
+
);
|
|
93
|
+
`);
|
|
94
|
+
}
|
|
95
|
+
function parseSessionJson(raw) {
|
|
96
|
+
try {
|
|
97
|
+
const parsed = JSON.parse(raw);
|
|
98
|
+
return parsed && typeof parsed === "object" && typeof parsed.kind === "string"
|
|
99
|
+
? parsed
|
|
100
|
+
: undefined;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function parseCallbackPayloadJson(raw) {
|
|
107
|
+
try {
|
|
108
|
+
const parsed = JSON.parse(raw);
|
|
109
|
+
if (!parsed || typeof parsed !== "object" || typeof parsed.kind !== "string") {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
if (parsed.kind === "account_action") {
|
|
113
|
+
if (parsed.action !== "refresh" &&
|
|
114
|
+
parsed.action !== "disable" &&
|
|
115
|
+
parsed.action !== "enable" &&
|
|
116
|
+
parsed.action !== "delete") {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
return typeof parsed.accountId === "string" && parsed.accountId
|
|
120
|
+
? parsed
|
|
121
|
+
: undefined;
|
|
122
|
+
}
|
|
123
|
+
if (parsed.kind === "renewal_plan") {
|
|
124
|
+
return typeof parsed.planId === "string" &&
|
|
125
|
+
parsed.planId &&
|
|
126
|
+
typeof parsed.days === "number" &&
|
|
127
|
+
Number.isInteger(parsed.days) &&
|
|
128
|
+
parsed.days > 0
|
|
129
|
+
? parsed
|
|
130
|
+
: undefined;
|
|
131
|
+
}
|
|
132
|
+
if (parsed.kind === "renewal_request_action") {
|
|
133
|
+
if (parsed.action !== "approve" &&
|
|
134
|
+
parsed.action !== "approve_rotate" &&
|
|
135
|
+
parsed.action !== "approve_override" &&
|
|
136
|
+
parsed.action !== "confirm_payment" &&
|
|
137
|
+
parsed.action !== "close" &&
|
|
138
|
+
parsed.action !== "view_customer" &&
|
|
139
|
+
parsed.action !== "show_reject_reasons" &&
|
|
140
|
+
parsed.action !== "reject_reason" &&
|
|
141
|
+
parsed.action !== "show_main_actions" &&
|
|
142
|
+
parsed.action !== "prompt_custom_days" &&
|
|
143
|
+
parsed.action !== "prompt_custom_reason") {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
if (typeof parsed.requestId !== "string" || !parsed.requestId) {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
if (parsed.action === "approve_override") {
|
|
150
|
+
return typeof parsed.overrideDays === "number" &&
|
|
151
|
+
Number.isInteger(parsed.overrideDays) &&
|
|
152
|
+
parsed.overrideDays > 0
|
|
153
|
+
? parsed
|
|
154
|
+
: undefined;
|
|
155
|
+
}
|
|
156
|
+
if (parsed.action === "reject_reason") {
|
|
157
|
+
return typeof parsed.resolution === "string" && parsed.resolution
|
|
158
|
+
? parsed
|
|
159
|
+
: undefined;
|
|
160
|
+
}
|
|
161
|
+
return parsed;
|
|
162
|
+
}
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
export function buildTelegramSessionScope(chatId, userId) {
|
|
170
|
+
return `${chatId}:${userId}`;
|
|
171
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { afterEach, test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { buildTelegramSessionScope, SqliteSessionStore } from "./sessions.js";
|
|
7
|
+
const tempDirs = [];
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
while (tempDirs.length > 0) {
|
|
10
|
+
const directory = tempDirs.pop();
|
|
11
|
+
if (directory) {
|
|
12
|
+
rmSync(directory, { recursive: true, force: true });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
test("SqliteSessionStore persists sessions across store recreation", () => {
|
|
17
|
+
const directory = mkdtempSync(path.join(tmpdir(), "telegram-session-store-"));
|
|
18
|
+
tempDirs.push(directory);
|
|
19
|
+
const dbFile = path.join(directory, "bot.sqlite");
|
|
20
|
+
const firstStore = SqliteSessionStore.create(dbFile, 60_000);
|
|
21
|
+
const scope = buildTelegramSessionScope("chat-1", "user-1");
|
|
22
|
+
firstStore.set(scope, {
|
|
23
|
+
kind: "awaiting_apply_model_input",
|
|
24
|
+
client: "codex",
|
|
25
|
+
providerId: "provider-1",
|
|
26
|
+
providerName: "Provider One",
|
|
27
|
+
models: ["gpt-5.5"],
|
|
28
|
+
});
|
|
29
|
+
const secondStore = SqliteSessionStore.create(dbFile, 60_000);
|
|
30
|
+
assert.deepEqual(secondStore.get(scope), {
|
|
31
|
+
kind: "awaiting_apply_model_input",
|
|
32
|
+
client: "codex",
|
|
33
|
+
providerId: "provider-1",
|
|
34
|
+
providerName: "Provider One",
|
|
35
|
+
models: ["gpt-5.5"],
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
test("SqliteSessionStore expires stale sessions", () => {
|
|
39
|
+
const directory = mkdtempSync(path.join(tmpdir(), "telegram-session-store-"));
|
|
40
|
+
tempDirs.push(directory);
|
|
41
|
+
const dbFile = path.join(directory, "bot.sqlite");
|
|
42
|
+
const store = SqliteSessionStore.create(dbFile, 100);
|
|
43
|
+
const scope = buildTelegramSessionScope("chat-2", "user-2");
|
|
44
|
+
store.set(scope, { kind: "awaiting_oauth_callback" }, 1_000);
|
|
45
|
+
assert.equal(store.get(scope, 1_050)?.kind, "awaiting_oauth_callback");
|
|
46
|
+
assert.equal(store.get(scope, 1_101), undefined);
|
|
47
|
+
});
|
|
48
|
+
test("SqliteSessionStore keeps sessions isolated by user within the same chat", () => {
|
|
49
|
+
const directory = mkdtempSync(path.join(tmpdir(), "telegram-session-store-"));
|
|
50
|
+
tempDirs.push(directory);
|
|
51
|
+
const dbFile = path.join(directory, "bot.sqlite");
|
|
52
|
+
const store = SqliteSessionStore.create(dbFile, 60_000);
|
|
53
|
+
store.set(buildTelegramSessionScope("chat-shared", "user-a"), { kind: "awaiting_oauth_callback" });
|
|
54
|
+
store.set(buildTelegramSessionScope("chat-shared", "user-b"), { kind: "awaiting_test_prompt" });
|
|
55
|
+
assert.equal(store.get(buildTelegramSessionScope("chat-shared", "user-a"))?.kind, "awaiting_oauth_callback");
|
|
56
|
+
assert.equal(store.get(buildTelegramSessionScope("chat-shared", "user-b"))?.kind, "awaiting_test_prompt");
|
|
57
|
+
});
|
|
58
|
+
test("SqliteSessionStore issues short callback tokens and resolves stored account actions", () => {
|
|
59
|
+
const directory = mkdtempSync(path.join(tmpdir(), "telegram-session-store-"));
|
|
60
|
+
tempDirs.push(directory);
|
|
61
|
+
const dbFile = path.join(directory, "bot.sqlite");
|
|
62
|
+
const store = SqliteSessionStore.create(dbFile, 60_000);
|
|
63
|
+
const token = store.issueCallbackToken({
|
|
64
|
+
kind: "account_action",
|
|
65
|
+
action: "delete",
|
|
66
|
+
accountId: "chatgpt-oauth:very.long.email.address@example.com",
|
|
67
|
+
});
|
|
68
|
+
assert.ok(token.length < 40);
|
|
69
|
+
assert.deepEqual(store.readCallbackToken(token), {
|
|
70
|
+
kind: "account_action",
|
|
71
|
+
action: "delete",
|
|
72
|
+
accountId: "chatgpt-oauth:very.long.email.address@example.com",
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
test("SqliteSessionStore resolves renewal confirm payment callback tokens", () => {
|
|
76
|
+
const directory = mkdtempSync(path.join(tmpdir(), "telegram-session-store-"));
|
|
77
|
+
tempDirs.push(directory);
|
|
78
|
+
const dbFile = path.join(directory, "bot.sqlite");
|
|
79
|
+
const store = SqliteSessionStore.create(dbFile, 60_000);
|
|
80
|
+
const token = store.issueCallbackToken({
|
|
81
|
+
kind: "renewal_request_action",
|
|
82
|
+
action: "confirm_payment",
|
|
83
|
+
requestId: "renewal-1",
|
|
84
|
+
});
|
|
85
|
+
assert.deepEqual(store.readCallbackToken(token), {
|
|
86
|
+
kind: "renewal_request_action",
|
|
87
|
+
action: "confirm_payment",
|
|
88
|
+
requestId: "renewal-1",
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
test("SqliteSessionStore supports longer callback token TTL for renewal review buttons", () => {
|
|
92
|
+
const directory = mkdtempSync(path.join(tmpdir(), "telegram-session-store-"));
|
|
93
|
+
tempDirs.push(directory);
|
|
94
|
+
const dbFile = path.join(directory, "bot.sqlite");
|
|
95
|
+
const store = SqliteSessionStore.create(dbFile, 60_000);
|
|
96
|
+
const token = store.issueCallbackToken({
|
|
97
|
+
kind: "renewal_request_action",
|
|
98
|
+
action: "approve",
|
|
99
|
+
requestId: "renewal-late-review",
|
|
100
|
+
}, 1_000, 3_600_000);
|
|
101
|
+
assert.deepEqual(store.readCallbackToken(token, 62_000), {
|
|
102
|
+
kind: "renewal_request_action",
|
|
103
|
+
action: "approve",
|
|
104
|
+
requestId: "renewal-late-review",
|
|
105
|
+
});
|
|
106
|
+
assert.equal(store.readCallbackToken(token, 3_601_001), undefined);
|
|
107
|
+
});
|