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.
Files changed (161) hide show
  1. package/README.md +56 -0
  2. package/cli.js +118 -0
  3. package/dist/anthropic-messages.js +383 -0
  4. package/dist/anthropic-messages.test.js +209 -0
  5. package/dist/audit-log.js +138 -0
  6. package/dist/audit-log.test.js +480 -0
  7. package/dist/billing-expiration.js +70 -0
  8. package/dist/billing-expiration.test.js +114 -0
  9. package/dist/billing.js +716 -0
  10. package/dist/billing.test.js +228 -0
  11. package/dist/chatgpt-oauth-store.js +240 -0
  12. package/dist/chatgpt-oauth-store.test.js +88 -0
  13. package/dist/chatgpt-oauth.js +118 -0
  14. package/dist/chatgpt-oauth.test.js +63 -0
  15. package/dist/chatgpt-provider-auth.js +60 -0
  16. package/dist/chatgpt-provider-auth.test.js +101 -0
  17. package/dist/client/app-icon.svg +17 -0
  18. package/dist/client/assets/index-C7Vvhst8.js +14 -0
  19. package/dist/client/assets/index-DpqgYK3L.css +1 -0
  20. package/dist/client/favicon.svg +17 -0
  21. package/dist/client/index.html +31 -0
  22. package/dist/client-config-apply.js +345 -0
  23. package/dist/client-config-apply.test.js +185 -0
  24. package/dist/client-token-limits.js +111 -0
  25. package/dist/client-token-limits.test.js +129 -0
  26. package/dist/codex-config.js +47 -0
  27. package/dist/codex-setup.js +87 -0
  28. package/dist/codex-setup.test.js +30 -0
  29. package/dist/config.js +314 -0
  30. package/dist/cost-analytics.js +31 -0
  31. package/dist/cost-analytics.test.js +38 -0
  32. package/dist/customer-key-access.js +126 -0
  33. package/dist/customer-key-access.test.js +178 -0
  34. package/dist/customer-keys.js +209 -0
  35. package/dist/customer-keys.test.js +68 -0
  36. package/dist/customer-usage.js +18 -0
  37. package/dist/customer-usage.test.js +55 -0
  38. package/dist/dashboard-auth.js +318 -0
  39. package/dist/dashboard-auth.test.js +133 -0
  40. package/dist/dashboard-serving.test.js +235 -0
  41. package/dist/error-response.js +174 -0
  42. package/dist/error-response.test.js +88 -0
  43. package/dist/forward.js +357 -0
  44. package/dist/health-websocket-manager.js +174 -0
  45. package/dist/http-rate-limit.js +36 -0
  46. package/dist/http-rate-limit.test.js +62 -0
  47. package/dist/kiro-auth.js +136 -0
  48. package/dist/kiro-auth.test.js +234 -0
  49. package/dist/kiro-codewhisperer.js +646 -0
  50. package/dist/kiro-codewhisperer.test.js +219 -0
  51. package/dist/kiro-device-login.js +338 -0
  52. package/dist/kiro-eventstream.js +219 -0
  53. package/dist/kiro-eventstream.test.js +79 -0
  54. package/dist/kiro-forward.js +401 -0
  55. package/dist/kiro-import-cli.js +69 -0
  56. package/dist/kiro-import.js +94 -0
  57. package/dist/kiro-import.test.js +125 -0
  58. package/dist/kiro-token-store.js +196 -0
  59. package/dist/kiro-token-store.test.js +207 -0
  60. package/dist/krouter-usage.js +243 -0
  61. package/dist/model-combo-repository.js +147 -0
  62. package/dist/model-routing.js +69 -0
  63. package/dist/model-routing.test.js +41 -0
  64. package/dist/normalize-request.js +531 -0
  65. package/dist/normalize-request.test.js +277 -0
  66. package/dist/omv-public-firewall.test.js +11 -0
  67. package/dist/package.json +17 -0
  68. package/dist/prompt-cache-state.js +146 -0
  69. package/dist/prompt-cache-state.test.js +71 -0
  70. package/dist/prompt-cache.js +229 -0
  71. package/dist/provider-health-service.js +404 -0
  72. package/dist/provider-request-parameters.js +107 -0
  73. package/dist/provider-request-parameters.test.js +26 -0
  74. package/dist/provider-routing.js +114 -0
  75. package/dist/provider-routing.test.js +64 -0
  76. package/dist/provider-usage.js +314 -0
  77. package/dist/request-timeout-policy.js +61 -0
  78. package/dist/request-timeout-policy.test.js +40 -0
  79. package/dist/response-cache.js +69 -0
  80. package/dist/response-cache.test.js +28 -0
  81. package/dist/routing-combo-repository.js +300 -0
  82. package/dist/routing-engine.js +377 -0
  83. package/dist/routing-integration.js +155 -0
  84. package/dist/routing-simulation-engine.js +326 -0
  85. package/dist/rtk-layer.js +483 -0
  86. package/dist/rtk-layer.test.js +198 -0
  87. package/dist/runtime-provider-repository.js +1742 -0
  88. package/dist/runtime-provider-repository.test.js +1177 -0
  89. package/dist/schema.js +118 -0
  90. package/dist/schema.test.js +16 -0
  91. package/dist/sepay-webhook.js +87 -0
  92. package/dist/sepay-webhook.test.js +142 -0
  93. package/dist/server-body-limit.test.js +35 -0
  94. package/dist/server-client-token-limits.test.js +161 -0
  95. package/dist/server-codex-config-setup.test.js +76 -0
  96. package/dist/server-http-rate-limit.test.js +80 -0
  97. package/dist/server-response-cache.test.js +105 -0
  98. package/dist/server-routes-alias.test.js +39 -0
  99. package/dist/server-sepay-webhook-security.test.js +59 -0
  100. package/dist/server.js +5906 -0
  101. package/dist/session-log.js +178 -0
  102. package/dist/tailnet-funnel-script.test.js +33 -0
  103. package/dist/telegram-bot/actions.js +118 -0
  104. package/dist/telegram-bot/admin-actions.js +103 -0
  105. package/dist/telegram-bot/auth.js +46 -0
  106. package/dist/telegram-bot/auth.test.js +1 -0
  107. package/dist/telegram-bot/bot-identity-repository.js +189 -0
  108. package/dist/telegram-bot/bot-identity-repository.test.js +78 -0
  109. package/dist/telegram-bot/callbacks.js +30 -0
  110. package/dist/telegram-bot/codex-config-delivery.js +38 -0
  111. package/dist/telegram-bot/codex-config-delivery.test.js +75 -0
  112. package/dist/telegram-bot/commands/accounts.js +140 -0
  113. package/dist/telegram-bot/commands/apikey.js +737 -0
  114. package/dist/telegram-bot/commands/apply.js +265 -0
  115. package/dist/telegram-bot/commands/clients.js +13 -0
  116. package/dist/telegram-bot/commands/customer-billing.test.js +271 -0
  117. package/dist/telegram-bot/commands/grant.js +138 -0
  118. package/dist/telegram-bot/commands/grant.test.js +217 -0
  119. package/dist/telegram-bot/commands/help.js +52 -0
  120. package/dist/telegram-bot/commands/me.js +53 -0
  121. package/dist/telegram-bot/commands/models.js +6 -0
  122. package/dist/telegram-bot/commands/oauth.js +64 -0
  123. package/dist/telegram-bot/commands/plans.js +96 -0
  124. package/dist/telegram-bot/commands/providers.js +27 -0
  125. package/dist/telegram-bot/commands/quota.js +10 -0
  126. package/dist/telegram-bot/commands/renew-user.js +139 -0
  127. package/dist/telegram-bot/commands/renew-user.test.js +184 -0
  128. package/dist/telegram-bot/commands/renew.js +1369 -0
  129. package/dist/telegram-bot/commands/renew.test.js +1633 -0
  130. package/dist/telegram-bot/commands/start.js +212 -0
  131. package/dist/telegram-bot/commands/start.test.js +280 -0
  132. package/dist/telegram-bot/commands/status.js +6 -0
  133. package/dist/telegram-bot/commands/tailscale.js +15 -0
  134. package/dist/telegram-bot/commands/tailscale.test.js +76 -0
  135. package/dist/telegram-bot/commands/test.js +51 -0
  136. package/dist/telegram-bot/commands/test.test.js +14 -0
  137. package/dist/telegram-bot/commands/usage.js +10 -0
  138. package/dist/telegram-bot/config.js +98 -0
  139. package/dist/telegram-bot/config.test.js +42 -0
  140. package/dist/telegram-bot/customer-actions.js +160 -0
  141. package/dist/telegram-bot/customer-api-keys.js +68 -0
  142. package/dist/telegram-bot/customer-billing.js +72 -0
  143. package/dist/telegram-bot/customer-workspace-repository.js +134 -0
  144. package/dist/telegram-bot/customer-workspace-repository.test.js +47 -0
  145. package/dist/telegram-bot/dashboard-login.js +39 -0
  146. package/dist/telegram-bot/format.js +140 -0
  147. package/dist/telegram-bot/grants.js +370 -0
  148. package/dist/telegram-bot/grants.test.js +290 -0
  149. package/dist/telegram-bot/index.js +85 -0
  150. package/dist/telegram-bot/message-cleanup.js +55 -0
  151. package/dist/telegram-bot/message-cleanup.test.js +77 -0
  152. package/dist/telegram-bot/message-format.js +45 -0
  153. package/dist/telegram-bot/message-format.test.js +10 -0
  154. package/dist/telegram-bot/proxy-client.js +174 -0
  155. package/dist/telegram-bot/rate-limit.js +95 -0
  156. package/dist/telegram-bot/rate-limit.test.js +58 -0
  157. package/dist/telegram-bot/sessions.js +171 -0
  158. package/dist/telegram-bot/sessions.test.js +107 -0
  159. package/dist/telegram-bot/telegram-adapter.js +126 -0
  160. package/dist/telegram-bot/worker.js +63 -0
  161. package/package.json +39 -0
@@ -0,0 +1,178 @@
1
+ import { appendFile, mkdir, readdir, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ const LATEST_DIR_NAME = "latest";
4
+ const DATE_DIR_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
5
+ const CACHE_HIT_STREAK_TTL_MS = 6 * 60 * 60 * 1000;
6
+ const MAX_CACHE_HIT_STREAK_SESSIONS = 2000;
7
+ let lastCleanupStartedAt = 0;
8
+ let cleanupPromise;
9
+ const cacheHitStreakBySession = new Map();
10
+ export function deriveSessionKey(body, traceContext) {
11
+ const promptCacheKey = readString(traceContext.promptCacheKey) ?? readString(body.prompt_cache_key);
12
+ if (promptCacheKey) {
13
+ return promptCacheKey;
14
+ }
15
+ const metadataUserId = readString(traceContext.metadataUserId);
16
+ if (metadataUserId) {
17
+ return metadataUserId;
18
+ }
19
+ const previousResponseId = readString(body.previous_response_id);
20
+ if (previousResponseId) {
21
+ return previousResponseId;
22
+ }
23
+ const user = readString(body.user);
24
+ if (user) {
25
+ return user;
26
+ }
27
+ return "unknown-session";
28
+ }
29
+ export function createSessionLogContext(logDir, sessionKey, retentionDays = 14, options) {
30
+ const fileName = `${sanitizeFileName(sessionKey)}.jsonl`;
31
+ const sessionFileFor = (date = new Date()) => path.join(logDir, formatLocalDate(date), fileName);
32
+ const latestSessionFile = () => path.join(logDir, LATEST_DIR_NAME, fileName);
33
+ return {
34
+ sessionKey,
35
+ sessionFileFor,
36
+ latestSessionFile,
37
+ async write(entry) {
38
+ const now = new Date();
39
+ const sessionFile = sessionFileFor(now);
40
+ const latestFile = latestSessionFile();
41
+ await Promise.all([
42
+ mkdir(path.dirname(sessionFile), { recursive: true }),
43
+ mkdir(path.dirname(latestFile), { recursive: true }),
44
+ ]);
45
+ const enrichedEntry = enrichCacheMetrics(sessionKey, entry, options?.cacheMetricsStore);
46
+ const payload = JSON.stringify({
47
+ ts: now.toISOString(),
48
+ sessionKey,
49
+ ...enrichedEntry,
50
+ });
51
+ await Promise.all([
52
+ appendFile(sessionFile, `${payload}\n`, "utf8"),
53
+ appendFile(latestFile, `${payload}\n`, "utf8"),
54
+ ]);
55
+ scheduleRetentionCleanup(logDir, retentionDays);
56
+ },
57
+ };
58
+ }
59
+ function enrichCacheMetrics(sessionKey, entry, cacheMetricsStore) {
60
+ const cachedTokens = typeof entry.cachedTokens === "number" ? entry.cachedTokens : undefined;
61
+ if (cachedTokens === undefined) {
62
+ return entry;
63
+ }
64
+ const persistedState = cacheMetricsStore?.recordCacheResult(sessionKey, cachedTokens);
65
+ if (persistedState) {
66
+ return {
67
+ ...entry,
68
+ cacheHit: persistedState.cacheHit,
69
+ consecutiveCacheHits: persistedState.consecutiveCacheHits,
70
+ };
71
+ }
72
+ pruneCacheHitStreaks();
73
+ const cacheHit = cachedTokens > 0;
74
+ const now = Date.now();
75
+ const consecutiveCacheHits = cacheHit
76
+ ? (cacheHitStreakBySession.get(sessionKey)?.count ?? 0) + 1
77
+ : 0;
78
+ cacheHitStreakBySession.set(sessionKey, {
79
+ count: consecutiveCacheHits,
80
+ updatedAt: now,
81
+ });
82
+ return {
83
+ ...entry,
84
+ cacheHit,
85
+ consecutiveCacheHits,
86
+ };
87
+ }
88
+ function scheduleRetentionCleanup(logDir, retentionDays) {
89
+ if (retentionDays <= 0) {
90
+ return;
91
+ }
92
+ const now = Date.now();
93
+ if (cleanupPromise) {
94
+ return;
95
+ }
96
+ if (now - lastCleanupStartedAt < 60_000) {
97
+ return;
98
+ }
99
+ lastCleanupStartedAt = now;
100
+ cleanupPromise = cleanupOldLogDirs(logDir, retentionDays).finally(() => {
101
+ cleanupPromise = undefined;
102
+ });
103
+ }
104
+ function pruneCacheHitStreaks() {
105
+ const now = Date.now();
106
+ for (const [sessionKey, state] of cacheHitStreakBySession) {
107
+ if (now - state.updatedAt > CACHE_HIT_STREAK_TTL_MS) {
108
+ cacheHitStreakBySession.delete(sessionKey);
109
+ }
110
+ }
111
+ if (cacheHitStreakBySession.size <= MAX_CACHE_HIT_STREAK_SESSIONS) {
112
+ return;
113
+ }
114
+ const overflow = cacheHitStreakBySession.size - MAX_CACHE_HIT_STREAK_SESSIONS;
115
+ let removed = 0;
116
+ for (const sessionKey of cacheHitStreakBySession.keys()) {
117
+ cacheHitStreakBySession.delete(sessionKey);
118
+ removed += 1;
119
+ if (removed >= overflow) {
120
+ break;
121
+ }
122
+ }
123
+ }
124
+ async function cleanupOldLogDirs(logDir, retentionDays) {
125
+ let entries;
126
+ try {
127
+ entries = await readdir(logDir);
128
+ }
129
+ catch (error) {
130
+ if (isMissingPathError(error)) {
131
+ return;
132
+ }
133
+ throw error;
134
+ }
135
+ const cutoff = startOfLocalDay(new Date());
136
+ cutoff.setDate(cutoff.getDate() - retentionDays);
137
+ await Promise.all(entries
138
+ .filter((entry) => entry !== LATEST_DIR_NAME && DATE_DIR_PATTERN.test(entry))
139
+ .filter((entry) => {
140
+ const parsed = parseLocalDate(entry);
141
+ return parsed !== undefined && parsed.getTime() < cutoff.getTime();
142
+ })
143
+ .map((entry) => rm(path.join(logDir, entry), { recursive: true, force: true })));
144
+ }
145
+ function startOfLocalDay(value) {
146
+ return new Date(value.getFullYear(), value.getMonth(), value.getDate());
147
+ }
148
+ function parseLocalDate(value) {
149
+ const match = DATE_DIR_PATTERN.exec(value);
150
+ if (!match) {
151
+ return undefined;
152
+ }
153
+ const [year, month, day] = value.split("-").map((part) => Number(part));
154
+ const parsed = new Date(year, month - 1, day);
155
+ if (parsed.getFullYear() !== year ||
156
+ parsed.getMonth() !== month - 1 ||
157
+ parsed.getDate() !== day) {
158
+ return undefined;
159
+ }
160
+ return parsed;
161
+ }
162
+ function isMissingPathError(error) {
163
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
164
+ }
165
+ function sanitizeFileName(value) {
166
+ const compact = value.trim().replace(/[^\w.-]+/g, "_");
167
+ const sanitized = compact.replace(/_+/g, "_").replace(/^_+|_+$/g, "");
168
+ return sanitized || "unknown-session";
169
+ }
170
+ function formatLocalDate(value) {
171
+ const year = value.getFullYear();
172
+ const month = String(value.getMonth() + 1).padStart(2, "0");
173
+ const day = String(value.getDate()).padStart(2, "0");
174
+ return `${year}-${month}-${day}`;
175
+ }
176
+ function readString(value) {
177
+ return typeof value === "string" && value.trim() ? value : undefined;
178
+ }
@@ -0,0 +1,33 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
3
+ import path from "node:path";
4
+ import test from "node:test";
5
+ const scriptPath = path.resolve(process.cwd(), "scripts", "tailnet-funnel.sh");
6
+ const bashLookup = spawnSync("sh", ["-c", "command -v bash"], {
7
+ encoding: "utf8",
8
+ });
9
+ const bashPath = bashLookup.status === 0 ? bashLookup.stdout.trim() : "";
10
+ test("tailnet funnel script rejects invalid public https port", { skip: !bashPath }, () => {
11
+ const result = spawnSync(bashPath, [scriptPath], {
12
+ encoding: "utf8",
13
+ env: {
14
+ ...process.env,
15
+ TAILSCALE_LOCAL_TARGET: "http://127.0.0.1:8318",
16
+ TAILSCALE_FUNNEL_HTTPS_PORT: "8444",
17
+ },
18
+ });
19
+ assert.notEqual(result.status, 0);
20
+ assert.match(`${result.stdout}${result.stderr}`, /TAILSCALE_FUNNEL_HTTPS_PORT/);
21
+ });
22
+ test("tailnet funnel script rejects non-localhost target", { skip: !bashPath }, () => {
23
+ const result = spawnSync(bashPath, [scriptPath], {
24
+ encoding: "utf8",
25
+ env: {
26
+ ...process.env,
27
+ TAILSCALE_LOCAL_TARGET: "http://192.168.0.10:8318",
28
+ TAILSCALE_FUNNEL_HTTPS_PORT: "443",
29
+ },
30
+ });
31
+ assert.notEqual(result.status, 0);
32
+ assert.match(`${result.stdout}${result.stderr}`, /TAILSCALE_LOCAL_TARGET must point at localhost/);
33
+ });
@@ -0,0 +1,118 @@
1
+ import { formatClientConfigs, formatHealthStatus, formatModels, formatOauthStatus, formatProviderDetails, formatProviders, formatProxyError, formatTestResult, } from "./format.js";
2
+ import { replyOrEditMessage } from "./callbacks.js";
3
+ import { ProxyClientError } from "./proxy-client.js";
4
+ export function getProxyErrorMessage(error) {
5
+ if (error instanceof ProxyClientError) {
6
+ return formatProxyError(error.body?.error ?? error);
7
+ }
8
+ return error instanceof Error ? error.message : "Unknown bot error";
9
+ }
10
+ export async function replyWithProxyError(ctx, error) {
11
+ await replyOrEditMessage(ctx, getProxyErrorMessage(error));
12
+ }
13
+ export async function loadStatusText(deps) {
14
+ const [health, providers, promptCache, usageStats] = await Promise.all([
15
+ deps.proxyClient.getHealth(),
16
+ deps.proxyClient.getProviders(),
17
+ deps.proxyClient.getLatestPromptCache(),
18
+ deps.proxyClient.getUsageStats(),
19
+ ]);
20
+ const usageSummary = summarizeUsageStats(usageStats?.stats);
21
+ return formatHealthStatus({
22
+ ...health,
23
+ activeProviderId: providers?.activeProviderId,
24
+ latestPromptCache: promptCache?.latest ?? null,
25
+ usageSummary,
26
+ });
27
+ }
28
+ export async function loadProvidersText(deps) {
29
+ return formatProviders(await deps.proxyClient.getProviders());
30
+ }
31
+ export async function loadClientsText(deps) {
32
+ return formatClientConfigs(await deps.proxyClient.getClientConfigs());
33
+ }
34
+ export async function loadModelsText(deps) {
35
+ return formatModels(await deps.proxyClient.getModels());
36
+ }
37
+ export async function loadOauthStatusText(deps) {
38
+ return formatOauthStatus(await deps.proxyClient.getOauthStatus());
39
+ }
40
+ export async function sendStatus(ctx, deps) {
41
+ try {
42
+ await ctx.reply(await loadStatusText(deps));
43
+ }
44
+ catch (error) {
45
+ await replyWithProxyError(ctx, error);
46
+ }
47
+ }
48
+ export async function sendProviders(ctx, deps) {
49
+ try {
50
+ await ctx.reply(await loadProvidersText(deps));
51
+ }
52
+ catch (error) {
53
+ await replyWithProxyError(ctx, error);
54
+ }
55
+ }
56
+ export async function sendClients(ctx, deps) {
57
+ try {
58
+ await ctx.reply(await loadClientsText(deps));
59
+ }
60
+ catch (error) {
61
+ await replyWithProxyError(ctx, error);
62
+ }
63
+ }
64
+ export async function sendOauthStatus(ctx, deps) {
65
+ try {
66
+ await ctx.reply(await loadOauthStatusText(deps));
67
+ }
68
+ catch (error) {
69
+ await replyWithProxyError(ctx, error);
70
+ }
71
+ }
72
+ export async function sendTestResult(ctx, deps, input) {
73
+ try {
74
+ const result = await deps.proxyClient.sendTestPrompt({
75
+ prompt: input.prompt,
76
+ model: input.model?.trim() || deps.config.defaultModel,
77
+ providerId: input.providerId?.trim() || undefined,
78
+ });
79
+ await ctx.reply(formatTestResult(result));
80
+ }
81
+ catch (error) {
82
+ await replyWithProxyError(ctx, error);
83
+ }
84
+ }
85
+ export async function sendModels(ctx, deps) {
86
+ try {
87
+ await ctx.reply(formatModels(await deps.proxyClient.getModels()));
88
+ }
89
+ catch (error) {
90
+ await replyWithProxyError(ctx, error);
91
+ }
92
+ }
93
+ export async function sendProviderDetails(ctx, deps, providerId) {
94
+ try {
95
+ const payload = await deps.proxyClient.getProviderDetails(providerId);
96
+ await ctx.reply(formatProviderDetails(payload.provider));
97
+ }
98
+ catch (error) {
99
+ await replyWithProxyError(ctx, error);
100
+ }
101
+ }
102
+ function summarizeUsageStats(stats) {
103
+ if (!stats || typeof stats !== "object") {
104
+ return [];
105
+ }
106
+ const summary = stats;
107
+ const lines = [];
108
+ if (typeof summary.totalRequests === "number") {
109
+ lines.push(`total requests: ${summary.totalRequests}`);
110
+ }
111
+ if (typeof summary.totalPromptTokens === "number") {
112
+ lines.push(`prompt tokens: ${summary.totalPromptTokens}`);
113
+ }
114
+ if (typeof summary.totalCompletionTokens === "number") {
115
+ lines.push(`completion tokens: ${summary.totalCompletionTokens}`);
116
+ }
117
+ return lines;
118
+ }
@@ -0,0 +1,103 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { replyOrEditMessage } from "./callbacks.js";
3
+ export const ADMIN_CALLBACK_ACTIONS = [
4
+ "status",
5
+ "clients",
6
+ "providers",
7
+ "models",
8
+ "oauth",
9
+ "plans",
10
+ "renewals",
11
+ "apikeys",
12
+ "apply",
13
+ "menu",
14
+ ];
15
+ export const ADMIN_CALLBACK_PATTERN = new RegExp(`^v1:admin:(${ADMIN_CALLBACK_ACTIONS.join("|")})$`);
16
+ export const ADMIN_ACTION_BUTTONS = {
17
+ status: { label: "📈 Status", callbackData: "v1:admin:status" },
18
+ clients: { label: "👥 Clients", callbackData: "v1:admin:clients" },
19
+ providers: { label: "🧭 Providers", callbackData: "v1:admin:providers" },
20
+ models: { label: "🧠 Models", callbackData: "v1:admin:models" },
21
+ plans: { label: "💳 Plans", callbackData: "v1:admin:plans" },
22
+ renewals: { label: "🧾 Renewals", callbackData: "v1:admin:renewals" },
23
+ apikeys: { label: "🔑 API Keys", callbackData: "v1:admin:apikeys" },
24
+ apply: { label: "⚙️ Apply", callbackData: "v1:admin:apply" },
25
+ oauth: { label: "🔐 OAuth", callbackData: "v1:admin:oauth" },
26
+ accounts: { label: "👤 Accounts", callbackData: "v1:acct:list" },
27
+ menu: { label: "⬅️ Admin", callbackData: "v1:admin:menu" },
28
+ };
29
+ export const ADMIN_ACTION_LOOPS = {
30
+ main: {
31
+ title: "Admin actions",
32
+ actions: ["status", "clients", "providers", "models", "plans", "renewals", "apikeys", "apply", "oauth", "accounts"],
33
+ },
34
+ proxy: {
35
+ title: "Proxy actions",
36
+ actions: ["status", "providers", "models", "oauth", "accounts", "menu"],
37
+ },
38
+ config: {
39
+ title: "Config actions",
40
+ actions: ["clients", "apply", "providers", "models", "menu"],
41
+ },
42
+ billing: {
43
+ title: "Billing actions",
44
+ actions: ["plans", "renewals", "apikeys", "menu"],
45
+ },
46
+ keys: {
47
+ title: "API key actions",
48
+ actions: ["apikeys", "plans", "renewals", "clients", "menu"],
49
+ },
50
+ apply: {
51
+ title: "Apply actions",
52
+ actions: ["apply", "clients", "status", "menu"],
53
+ },
54
+ accounts: {
55
+ title: "Account actions",
56
+ actions: ["accounts", "oauth", "status", "menu"],
57
+ },
58
+ };
59
+ export function buildAdminActionKeyboard(actions) {
60
+ const keyboard = new InlineKeyboard();
61
+ actions.forEach((action, index) => {
62
+ const button = ADMIN_ACTION_BUTTONS[action];
63
+ keyboard.text(button.label, button.callbackData);
64
+ if (index % 2 === 1 && index < actions.length - 1) {
65
+ keyboard.row();
66
+ }
67
+ });
68
+ return keyboard;
69
+ }
70
+ export function buildAdminStartKeyboard() {
71
+ return buildAdminActionKeyboard(ADMIN_ACTION_LOOPS.main.actions);
72
+ }
73
+ export function mergeInlineKeyboards(...keyboards) {
74
+ const rows = keyboards.flatMap((keyboard) => {
75
+ if (!keyboard) {
76
+ return [];
77
+ }
78
+ const raw = JSON.parse(JSON.stringify(keyboard));
79
+ return Array.isArray(raw.inline_keyboard) ? raw.inline_keyboard : [];
80
+ });
81
+ return rows.length > 0 ? { inline_keyboard: rows } : undefined;
82
+ }
83
+ export async function replyAdminActionLoop(ctx, loop = "main") {
84
+ const definition = ADMIN_ACTION_LOOPS[loop];
85
+ await replyOrEditMessage(ctx, definition.title, {
86
+ reply_markup: buildAdminActionKeyboard(definition.actions),
87
+ });
88
+ }
89
+ export async function renderAdminScreen(ctx, input) {
90
+ const loopKeyboard = buildAdminActionKeyboard(ADMIN_ACTION_LOOPS[input.loop].actions);
91
+ await replyOrEditMessage(ctx, input.text, {
92
+ reply_markup: mergeInlineKeyboards(input.primaryKeyboard, loopKeyboard),
93
+ });
94
+ }
95
+ export function buildApplyClientKeyboard(includeMenu = true) {
96
+ const keyboard = new InlineKeyboard()
97
+ .text("Hermes", "v1:apply:client:hermes")
98
+ .text("Codex", "v1:apply:client:codex");
99
+ if (includeMenu) {
100
+ keyboard.row().text(ADMIN_ACTION_BUTTONS.menu.label, ADMIN_ACTION_BUTTONS.menu.callbackData);
101
+ }
102
+ return keyboard;
103
+ }
@@ -0,0 +1,46 @@
1
+ function isAllowed(ctx, config) {
2
+ const fromId = ctx.from?.id?.toString();
3
+ const chatId = ctx.chat?.id?.toString();
4
+ const hasEmergencyUserLockdown = config.allowedUserIds.size > 0;
5
+ const isOwnerOrAdmin = !!fromId && (config.ownerUserIds.has(fromId) || config.adminUserIds.has(fromId));
6
+ const userAllowed = fromId
7
+ ? isOwnerOrAdmin ||
8
+ config.allowedUserIds.has(fromId) ||
9
+ (!hasEmergencyUserLockdown && config.publicSignupEnabled)
10
+ : false;
11
+ const chatAllowed = config.allowedChatIds.size === 0 || (chatId ? config.allowedChatIds.has(chatId) : false);
12
+ return userAllowed && chatAllowed;
13
+ }
14
+ export function isAdmin(ctx, config) {
15
+ const fromId = ctx.from?.id?.toString();
16
+ return !!fromId && (config.ownerUserIds.has(fromId) || config.adminUserIds.has(fromId));
17
+ }
18
+ export function createAllowlistMiddleware(config) {
19
+ return async (ctx, next) => {
20
+ if (!isAllowed(ctx, config)) {
21
+ await ctx.reply("🔒 Access restricted\nThis bot is restricted. Your user or chat is not authorized.");
22
+ return;
23
+ }
24
+ await next();
25
+ };
26
+ }
27
+ export function createCustomerCommandMiddleware(config) {
28
+ const customerCommands = new Set(["start", "help", "me", "apikey", "usage", "quota", "renew", "tailscale"]);
29
+ return async (ctx, next) => {
30
+ if (isAdmin(ctx, config)) {
31
+ await next();
32
+ return;
33
+ }
34
+ const text = ctx.message && "text" in ctx.message ? ctx.message.text : undefined;
35
+ const command = parseCommand(text);
36
+ if (!command || customerCommands.has(command)) {
37
+ await next();
38
+ return;
39
+ }
40
+ await ctx.reply("🔒 Admin-only command\nThis command is admin-only. Use /apikey to view your Responses API key.");
41
+ };
42
+ }
43
+ function parseCommand(text) {
44
+ const match = text?.trim().match(/^\/([A-Za-z0-9_]+)(?:@[A-Za-z0-9_]+)?(?:\s|$)/);
45
+ return match?.[1]?.toLowerCase();
46
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,189 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import path from "node:path";
3
+ import BetterSqlite3 from "better-sqlite3";
4
+ export class BotIdentityRepository {
5
+ db;
6
+ constructor(db) {
7
+ this.db = db;
8
+ }
9
+ static create(dbFile) {
10
+ mkdirSync(path.dirname(dbFile), { recursive: true });
11
+ const db = new BetterSqlite3(dbFile);
12
+ ensureIdentitySchema(db);
13
+ return new BotIdentityRepository(db);
14
+ }
15
+ upsertUser(input) {
16
+ const now = (input.now ?? new Date()).toISOString();
17
+ const existing = this.getUser(input.telegramUserId);
18
+ const role = existing?.role ?? input.defaultRole ?? "customer";
19
+ const status = existing?.status ?? input.defaultStatus ?? "active";
20
+ this.db
21
+ .prepare(`INSERT INTO telegram_users (
22
+ telegram_user_id,
23
+ username,
24
+ first_name,
25
+ last_name,
26
+ language_code,
27
+ role,
28
+ status,
29
+ created_at,
30
+ updated_at,
31
+ last_seen_at
32
+ )
33
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
34
+ ON CONFLICT(telegram_user_id) DO UPDATE SET
35
+ username = excluded.username,
36
+ first_name = excluded.first_name,
37
+ last_name = excluded.last_name,
38
+ language_code = excluded.language_code,
39
+ updated_at = excluded.updated_at,
40
+ last_seen_at = excluded.last_seen_at`)
41
+ .run(input.telegramUserId, input.username ?? null, input.firstName ?? null, input.lastName ?? null, input.languageCode ?? null, role, status, existing?.createdAt ?? now, now, now);
42
+ return this.getUser(input.telegramUserId);
43
+ }
44
+ getUser(telegramUserId) {
45
+ const row = this.db
46
+ .prepare(`SELECT
47
+ telegram_user_id,
48
+ username,
49
+ first_name,
50
+ last_name,
51
+ language_code,
52
+ role,
53
+ status,
54
+ created_at,
55
+ updated_at,
56
+ last_seen_at
57
+ FROM telegram_users
58
+ WHERE telegram_user_id = ?`)
59
+ .get(telegramUserId);
60
+ return row ? mapTelegramUserRow(row) : undefined;
61
+ }
62
+ setUserStatus(telegramUserId, status, now = new Date()) {
63
+ this.db
64
+ .prepare(`UPDATE telegram_users
65
+ SET status = ?, updated_at = ?
66
+ WHERE telegram_user_id = ?`)
67
+ .run(status, now.toISOString(), telegramUserId);
68
+ }
69
+ upsertChat(input) {
70
+ const now = (input.now ?? new Date()).toISOString();
71
+ const existing = this.getChat(input.telegramChatId);
72
+ this.db
73
+ .prepare(`INSERT INTO telegram_chats (
74
+ telegram_chat_id,
75
+ chat_type,
76
+ title,
77
+ created_at,
78
+ updated_at,
79
+ last_seen_at
80
+ )
81
+ VALUES (?, ?, ?, ?, ?, ?)
82
+ ON CONFLICT(telegram_chat_id) DO UPDATE SET
83
+ chat_type = excluded.chat_type,
84
+ title = excluded.title,
85
+ updated_at = excluded.updated_at,
86
+ last_seen_at = excluded.last_seen_at`)
87
+ .run(input.telegramChatId, input.chatType, input.title ?? null, existing?.createdAt ?? now, now, now);
88
+ return this.getChat(input.telegramChatId);
89
+ }
90
+ getChat(telegramChatId) {
91
+ const row = this.db
92
+ .prepare(`SELECT
93
+ telegram_chat_id,
94
+ chat_type,
95
+ title,
96
+ created_at,
97
+ updated_at,
98
+ last_seen_at
99
+ FROM telegram_chats
100
+ WHERE telegram_chat_id = ?`)
101
+ .get(telegramChatId);
102
+ return row ? mapTelegramChatRow(row) : undefined;
103
+ }
104
+ upsertMembership(input) {
105
+ const now = (input.now ?? new Date()).toISOString();
106
+ const role = input.role?.trim() || "member";
107
+ this.db
108
+ .prepare(`INSERT INTO telegram_chat_memberships (
109
+ telegram_user_id,
110
+ telegram_chat_id,
111
+ role,
112
+ created_at,
113
+ updated_at
114
+ )
115
+ VALUES (?, ?, ?, ?, ?)
116
+ ON CONFLICT(telegram_user_id, telegram_chat_id) DO UPDATE SET
117
+ role = excluded.role,
118
+ updated_at = excluded.updated_at`)
119
+ .run(input.telegramUserId, input.telegramChatId, role, now, now);
120
+ }
121
+ }
122
+ function ensureIdentitySchema(db) {
123
+ db.exec(`
124
+ CREATE TABLE IF NOT EXISTS telegram_users (
125
+ telegram_user_id TEXT PRIMARY KEY,
126
+ username TEXT,
127
+ first_name TEXT,
128
+ last_name TEXT,
129
+ language_code TEXT,
130
+ role TEXT NOT NULL DEFAULT 'customer',
131
+ status TEXT NOT NULL DEFAULT 'active',
132
+ created_at TEXT NOT NULL,
133
+ updated_at TEXT NOT NULL,
134
+ last_seen_at TEXT
135
+ );
136
+
137
+ CREATE TABLE IF NOT EXISTS telegram_chats (
138
+ telegram_chat_id TEXT PRIMARY KEY,
139
+ chat_type TEXT NOT NULL,
140
+ title TEXT,
141
+ created_at TEXT NOT NULL,
142
+ updated_at TEXT NOT NULL,
143
+ last_seen_at TEXT
144
+ );
145
+
146
+ CREATE TABLE IF NOT EXISTS telegram_chat_memberships (
147
+ telegram_user_id TEXT NOT NULL,
148
+ telegram_chat_id TEXT NOT NULL,
149
+ role TEXT NOT NULL DEFAULT 'member',
150
+ created_at TEXT NOT NULL,
151
+ updated_at TEXT NOT NULL,
152
+ PRIMARY KEY (telegram_user_id, telegram_chat_id)
153
+ );
154
+ `);
155
+ }
156
+ function mapTelegramUserRow(row) {
157
+ return {
158
+ telegramUserId: row.telegram_user_id,
159
+ username: row.username ?? undefined,
160
+ firstName: row.first_name ?? undefined,
161
+ lastName: row.last_name ?? undefined,
162
+ languageCode: row.language_code ?? undefined,
163
+ role: normalizeRole(row.role),
164
+ status: normalizeStatus(row.status),
165
+ createdAt: row.created_at,
166
+ updatedAt: row.updated_at,
167
+ lastSeenAt: row.last_seen_at ?? undefined,
168
+ };
169
+ }
170
+ function mapTelegramChatRow(row) {
171
+ return {
172
+ telegramChatId: row.telegram_chat_id,
173
+ chatType: row.chat_type,
174
+ title: row.title ?? undefined,
175
+ createdAt: row.created_at,
176
+ updatedAt: row.updated_at,
177
+ lastSeenAt: row.last_seen_at ?? undefined,
178
+ };
179
+ }
180
+ function normalizeRole(value) {
181
+ return value === "owner" || value === "admin" || value === "support" || value === "customer"
182
+ ? value
183
+ : "customer";
184
+ }
185
+ function normalizeStatus(value) {
186
+ return value === "active" || value === "pending_approval" || value === "blocked"
187
+ ? value
188
+ : "active";
189
+ }