ctb 1.0.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/src/utils.ts ADDED
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Utility functions for Claude Telegram Bot.
3
+ *
4
+ * Audit logging, voice transcription, typing indicator.
5
+ */
6
+
7
+ import type { Context } from "grammy";
8
+ import type { Chat } from "grammy/types";
9
+ import OpenAI from "openai";
10
+ import {
11
+ AUDIT_LOG_JSON,
12
+ AUDIT_LOG_PATH,
13
+ OPENAI_API_KEY,
14
+ TRANSCRIPTION_AVAILABLE,
15
+ TRANSCRIPTION_PROMPT,
16
+ } from "./config";
17
+ import type { AuditEvent } from "./types";
18
+
19
+ // ============== OpenAI Client ==============
20
+
21
+ let openaiClient: OpenAI | null = null;
22
+ if (OPENAI_API_KEY && TRANSCRIPTION_AVAILABLE) {
23
+ openaiClient = new OpenAI({ apiKey: OPENAI_API_KEY });
24
+ }
25
+
26
+ // ============== Audit Logging ==============
27
+
28
+ async function writeAuditLog(event: AuditEvent): Promise<void> {
29
+ try {
30
+ let content: string;
31
+ if (AUDIT_LOG_JSON) {
32
+ content = JSON.stringify(event) + "\n";
33
+ } else {
34
+ // Plain text format for readability
35
+ const lines = ["\n" + "=".repeat(60)];
36
+ for (const [key, value] of Object.entries(event)) {
37
+ let displayValue = value;
38
+ if (
39
+ (key === "content" || key === "response") &&
40
+ String(value).length > 500
41
+ ) {
42
+ displayValue = String(value).slice(0, 500) + "...";
43
+ }
44
+ lines.push(`${key}: ${displayValue}`);
45
+ }
46
+ content = lines.join("\n") + "\n";
47
+ }
48
+
49
+ // Append to audit log file
50
+ const fs = await import("fs/promises");
51
+ await fs.appendFile(AUDIT_LOG_PATH, content);
52
+ } catch (error) {
53
+ console.error("Failed to write audit log:", error);
54
+ }
55
+ }
56
+
57
+ export async function auditLog(
58
+ userId: number,
59
+ username: string,
60
+ messageType: string,
61
+ content: string,
62
+ response = "",
63
+ ): Promise<void> {
64
+ const event: AuditEvent = {
65
+ timestamp: new Date().toISOString(),
66
+ event: "message",
67
+ user_id: userId,
68
+ username,
69
+ message_type: messageType,
70
+ content,
71
+ };
72
+ if (response) {
73
+ event.response = response;
74
+ }
75
+ await writeAuditLog(event);
76
+ }
77
+
78
+ export async function auditLogAuth(
79
+ userId: number,
80
+ username: string,
81
+ authorized: boolean,
82
+ ): Promise<void> {
83
+ await writeAuditLog({
84
+ timestamp: new Date().toISOString(),
85
+ event: "auth",
86
+ user_id: userId,
87
+ username,
88
+ authorized,
89
+ });
90
+ }
91
+
92
+ export async function auditLogTool(
93
+ userId: number,
94
+ username: string,
95
+ toolName: string,
96
+ toolInput: Record<string, unknown>,
97
+ blocked = false,
98
+ reason = "",
99
+ ): Promise<void> {
100
+ const event: AuditEvent = {
101
+ timestamp: new Date().toISOString(),
102
+ event: "tool_use",
103
+ user_id: userId,
104
+ username,
105
+ tool_name: toolName,
106
+ tool_input: toolInput,
107
+ blocked,
108
+ };
109
+ if (blocked && reason) {
110
+ event.reason = reason;
111
+ }
112
+ await writeAuditLog(event);
113
+ }
114
+
115
+ export async function auditLogError(
116
+ userId: number,
117
+ username: string,
118
+ error: string,
119
+ context = "",
120
+ ): Promise<void> {
121
+ const event: AuditEvent = {
122
+ timestamp: new Date().toISOString(),
123
+ event: "error",
124
+ user_id: userId,
125
+ username,
126
+ error,
127
+ };
128
+ if (context) {
129
+ event.context = context;
130
+ }
131
+ await writeAuditLog(event);
132
+ }
133
+
134
+ export async function auditLogRateLimit(
135
+ userId: number,
136
+ username: string,
137
+ retryAfter: number,
138
+ ): Promise<void> {
139
+ await writeAuditLog({
140
+ timestamp: new Date().toISOString(),
141
+ event: "rate_limit",
142
+ user_id: userId,
143
+ username,
144
+ retry_after: retryAfter,
145
+ });
146
+ }
147
+
148
+ // ============== Voice Transcription ==============
149
+
150
+ export async function transcribeVoice(
151
+ filePath: string,
152
+ ): Promise<string | null> {
153
+ if (!openaiClient) {
154
+ console.warn("OpenAI client not available for transcription");
155
+ return null;
156
+ }
157
+
158
+ try {
159
+ const file = Bun.file(filePath);
160
+ const transcript = await openaiClient.audio.transcriptions.create({
161
+ model: "gpt-4o-transcribe",
162
+ file: file,
163
+ prompt: TRANSCRIPTION_PROMPT,
164
+ });
165
+ return transcript.text;
166
+ } catch (error) {
167
+ console.error("Transcription failed:", error);
168
+ return null;
169
+ }
170
+ }
171
+
172
+ // ============== Typing Indicator ==============
173
+
174
+ export interface TypingController {
175
+ stop: () => void;
176
+ }
177
+
178
+ export function startTypingIndicator(ctx: Context): TypingController {
179
+ let running = true;
180
+
181
+ const loop = async () => {
182
+ while (running) {
183
+ try {
184
+ await ctx.replyWithChatAction("typing");
185
+ } catch (error) {
186
+ // Stop loop if context is no longer valid
187
+ if (
188
+ String(error).includes("chat not found") ||
189
+ String(error).includes("bot was blocked")
190
+ ) {
191
+ running = false;
192
+ return;
193
+ }
194
+ console.debug("Typing indicator failed:", error);
195
+ }
196
+ await Bun.sleep(4000);
197
+ }
198
+ };
199
+
200
+ // Start the loop with proper error handling
201
+ loop().catch((error) => {
202
+ console.debug("Typing indicator loop error:", error);
203
+ });
204
+
205
+ return {
206
+ stop: () => {
207
+ running = false;
208
+ },
209
+ };
210
+ }
211
+
212
+ // ============== Message Interrupt ==============
213
+
214
+ // Import session lazily to avoid circular dependency
215
+ let sessionModule: {
216
+ session: {
217
+ isRunning: boolean;
218
+ stop: () => Promise<"stopped" | "pending" | false>;
219
+ markInterrupt: () => void;
220
+ clearStopRequested: () => void;
221
+ };
222
+ } | null = null;
223
+
224
+ export async function checkInterrupt(text: string): Promise<string> {
225
+ if (!text || !text.startsWith("!")) {
226
+ return text;
227
+ }
228
+
229
+ // Lazy import to avoid circular dependency
230
+ if (!sessionModule) {
231
+ sessionModule = await import("./session");
232
+ }
233
+
234
+ const strippedText = text.slice(1).trimStart();
235
+
236
+ if (sessionModule.session.isRunning) {
237
+ console.log("! prefix - interrupting current query");
238
+ sessionModule.session.markInterrupt();
239
+ await sessionModule.session.stop();
240
+ await Bun.sleep(100);
241
+ // Clear stopRequested so the new message can proceed
242
+ sessionModule.session.clearStopRequested();
243
+ }
244
+
245
+ return strippedText;
246
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup & latest features
4
+ "lib": ["ESNext"],
5
+ "target": "ESNext",
6
+ "module": "Preserve",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "allowJs": true,
10
+
11
+ // Bundler mode
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "noEmit": true,
16
+
17
+ // Best practices
18
+ "strict": true,
19
+ "skipLibCheck": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+ "noUncheckedIndexedAccess": true,
22
+ "noImplicitOverride": true,
23
+
24
+ // Some stricter flags (disabled by default)
25
+ "noUnusedLocals": false,
26
+ "noUnusedParameters": false,
27
+ "noPropertyAccessFromIndexSignature": false
28
+ }
29
+ }