@gonzih/cc-tg 0.7.1 → 0.7.2
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/dist/bot.d.ts +71 -0
- package/dist/bot.js +1183 -0
- package/dist/claude.d.ts +54 -0
- package/dist/claude.js +208 -0
- package/dist/cron.d.ts +39 -0
- package/dist/cron.js +148 -0
- package/dist/formatter.d.ts +25 -0
- package/dist/formatter.js +122 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +169 -0
- package/dist/notifier.d.ts +36 -0
- package/dist/notifier.js +105 -0
- package/dist/tokens.d.ts +22 -0
- package/dist/tokens.js +56 -0
- package/dist/usage-limit.d.ts +7 -0
- package/dist/usage-limit.js +29 -0
- package/dist/voice.d.ts +13 -0
- package/dist/voice.js +124 -0
- package/package.json +1 -1
package/dist/index.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cc-tg — Claude Code Telegram bot
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* npx @gonzih/cc-tg
|
|
7
|
+
*
|
|
8
|
+
* Required env:
|
|
9
|
+
* TELEGRAM_BOT_TOKEN — from @BotFather
|
|
10
|
+
* CLAUDE_CODE_TOKEN — your Claude Code OAuth token (or ANTHROPIC_API_KEY)
|
|
11
|
+
*
|
|
12
|
+
* Optional env:
|
|
13
|
+
* ALLOWED_USER_IDS — comma-separated Telegram user IDs (leave empty to allow all)
|
|
14
|
+
* GROUP_CHAT_IDS — comma-separated Telegram group/supergroup chat IDs (leave empty to allow all groups)
|
|
15
|
+
* CWD — working directory for Claude Code (default: process.cwd())
|
|
16
|
+
*/
|
|
17
|
+
import { createServer, createConnection } from "net";
|
|
18
|
+
import { unlinkSync, readFileSync } from "fs";
|
|
19
|
+
import { tmpdir } from "os";
|
|
20
|
+
import os from "os";
|
|
21
|
+
import { join, dirname } from "path";
|
|
22
|
+
import { fileURLToPath } from "url";
|
|
23
|
+
import TelegramBot from "node-telegram-bot-api";
|
|
24
|
+
import { CcTgBot } from "./bot.js";
|
|
25
|
+
import { loadTokens } from "./tokens.js";
|
|
26
|
+
import { Registry, startControlServer } from "@gonzih/agent-ops";
|
|
27
|
+
import { Redis } from "ioredis";
|
|
28
|
+
import { startNotifier } from "./notifier.js";
|
|
29
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
30
|
+
const __dirname = dirname(__filename);
|
|
31
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
|
|
32
|
+
// Make lock socket unique per bot token so multiple users on the same machine don't collide
|
|
33
|
+
const _tokenHash = Buffer.from(process.env.TELEGRAM_BOT_TOKEN ?? "default").toString("base64").replace(/[^a-z0-9]/gi, "").slice(0, 16);
|
|
34
|
+
const LOCK_SOCKET = join(tmpdir(), `cc-tg-${_tokenHash}.sock`);
|
|
35
|
+
function acquireLock() {
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
const server = createServer();
|
|
38
|
+
server.listen(LOCK_SOCKET, () => {
|
|
39
|
+
// Bound successfully — we own the lock. Socket auto-released on any exit incl. SIGKILL.
|
|
40
|
+
resolve(true);
|
|
41
|
+
});
|
|
42
|
+
server.on("error", (err) => {
|
|
43
|
+
if (err.code !== "EADDRINUSE") {
|
|
44
|
+
resolve(true); // unrelated error, proceed
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
// Socket path exists — probe if anything is actually listening
|
|
48
|
+
const probe = createConnection(LOCK_SOCKET);
|
|
49
|
+
probe.on("connect", () => {
|
|
50
|
+
probe.destroy();
|
|
51
|
+
console.error("[cc-tg] Another instance is already running. Exiting.");
|
|
52
|
+
resolve(false);
|
|
53
|
+
});
|
|
54
|
+
probe.on("error", () => {
|
|
55
|
+
// Nothing listening — stale socket, remove and retry
|
|
56
|
+
try {
|
|
57
|
+
unlinkSync(LOCK_SOCKET);
|
|
58
|
+
}
|
|
59
|
+
catch { }
|
|
60
|
+
const retry = createServer();
|
|
61
|
+
retry.listen(LOCK_SOCKET, () => resolve(true));
|
|
62
|
+
retry.on("error", () => resolve(true)); // give up on lock, just start
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const lockAcquired = await acquireLock();
|
|
68
|
+
if (!lockAcquired) {
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
function required(name) {
|
|
72
|
+
const val = process.env[name];
|
|
73
|
+
if (!val) {
|
|
74
|
+
console.error(`
|
|
75
|
+
ERROR: ${name} is not set.
|
|
76
|
+
|
|
77
|
+
cc-tg requires:
|
|
78
|
+
TELEGRAM_BOT_TOKEN — get one from @BotFather on Telegram
|
|
79
|
+
CLAUDE_CODE_TOKEN — your Claude Code OAuth token
|
|
80
|
+
|
|
81
|
+
Set them and run again:
|
|
82
|
+
TELEGRAM_BOT_TOKEN=xxx CLAUDE_CODE_TOKEN=yyy npx @gonzih/cc-tg
|
|
83
|
+
|
|
84
|
+
Or add to your shell profile / .env file.
|
|
85
|
+
`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
return val;
|
|
89
|
+
}
|
|
90
|
+
const telegramToken = required("TELEGRAM_BOT_TOKEN");
|
|
91
|
+
// Accept CLAUDE_CODE_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, or ANTHROPIC_API_KEY
|
|
92
|
+
const claudeToken = process.env.CLAUDE_CODE_TOKEN ??
|
|
93
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN ??
|
|
94
|
+
process.env.ANTHROPIC_API_KEY;
|
|
95
|
+
if (!claudeToken) {
|
|
96
|
+
console.error(`
|
|
97
|
+
ERROR: No Claude token set. Set one of: CLAUDE_CODE_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, or ANTHROPIC_API_KEY.
|
|
98
|
+
|
|
99
|
+
Set one and run again:
|
|
100
|
+
TELEGRAM_BOT_TOKEN=xxx CLAUDE_CODE_TOKEN=yyy npx @gonzih/cc-tg
|
|
101
|
+
`);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
// Load OAuth token pool (supports CLAUDE_CODE_OAUTH_TOKENS for multi-account rotation)
|
|
105
|
+
const tokenPool = loadTokens();
|
|
106
|
+
if (tokenPool.length > 1) {
|
|
107
|
+
console.log(`[cc-tg] Token pool loaded: ${tokenPool.length} tokens — will rotate on usage limit`);
|
|
108
|
+
}
|
|
109
|
+
const allowedUserIds = process.env.ALLOWED_USER_IDS
|
|
110
|
+
? process.env.ALLOWED_USER_IDS.split(",").map((s) => parseInt(s.trim(), 10)).filter(Boolean)
|
|
111
|
+
: [];
|
|
112
|
+
const groupChatIds = process.env.GROUP_CHAT_IDS
|
|
113
|
+
? process.env.GROUP_CHAT_IDS.split(",").map((s) => parseInt(s.trim(), 10)).filter(Boolean)
|
|
114
|
+
: [];
|
|
115
|
+
const cwd = process.env.CWD ?? process.cwd();
|
|
116
|
+
const bot = new CcTgBot({
|
|
117
|
+
telegramToken,
|
|
118
|
+
claudeToken,
|
|
119
|
+
cwd,
|
|
120
|
+
allowedUserIds,
|
|
121
|
+
groupChatIds,
|
|
122
|
+
});
|
|
123
|
+
// agent-ops: optional self-registration + HTTP control endpoint
|
|
124
|
+
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379";
|
|
125
|
+
const namespace = process.env.CC_AGENT_NAMESPACE || "default";
|
|
126
|
+
let sharedRedis = null;
|
|
127
|
+
if (process.env.CC_AGENT_OPS_PORT) {
|
|
128
|
+
const botInfo = await bot.getMe();
|
|
129
|
+
sharedRedis = new Redis(redisUrl);
|
|
130
|
+
const registry = new Registry(sharedRedis);
|
|
131
|
+
await registry.register({
|
|
132
|
+
namespace,
|
|
133
|
+
hostname: os.hostname(),
|
|
134
|
+
user: os.userInfo().username,
|
|
135
|
+
pid: String(process.pid),
|
|
136
|
+
version: pkg.version,
|
|
137
|
+
cwd: process.env.CWD || process.cwd(),
|
|
138
|
+
control_port: process.env.CC_AGENT_OPS_PORT,
|
|
139
|
+
bot_username: botInfo.username ?? "",
|
|
140
|
+
started_at: new Date().toISOString(),
|
|
141
|
+
});
|
|
142
|
+
setInterval(() => registry.heartbeat(namespace), 60_000);
|
|
143
|
+
startControlServer(Number(process.env.CC_AGENT_OPS_PORT), {
|
|
144
|
+
namespace,
|
|
145
|
+
version: pkg.version,
|
|
146
|
+
logFile: process.env.CC_AGENT_LOG_FILE || process.env.LOG_FILE,
|
|
147
|
+
});
|
|
148
|
+
console.log(`[ops] control server on port ${process.env.CC_AGENT_OPS_PORT}`);
|
|
149
|
+
}
|
|
150
|
+
// Notifier — subscribe to cca:notify:{namespace} and cca:chat:incoming:{namespace}
|
|
151
|
+
const notifyChatId = process.env.CC_AGENT_NOTIFY_CHAT_ID
|
|
152
|
+
? Number(process.env.CC_AGENT_NOTIFY_CHAT_ID)
|
|
153
|
+
: null;
|
|
154
|
+
if (notifyChatId) {
|
|
155
|
+
if (!sharedRedis)
|
|
156
|
+
sharedRedis = new Redis(redisUrl);
|
|
157
|
+
const notifierBot = new TelegramBot(telegramToken, { polling: false });
|
|
158
|
+
startNotifier(notifierBot, notifyChatId, namespace, sharedRedis, (cid, text) => bot.handleUserMessage(cid, text));
|
|
159
|
+
console.log(`[notifier] started for namespace=${namespace} chatId=${notifyChatId}`);
|
|
160
|
+
}
|
|
161
|
+
process.on("SIGINT", () => {
|
|
162
|
+
console.log("\nShutting down...");
|
|
163
|
+
bot.stop();
|
|
164
|
+
process.exit(0);
|
|
165
|
+
});
|
|
166
|
+
process.on("SIGTERM", () => {
|
|
167
|
+
bot.stop();
|
|
168
|
+
process.exit(0);
|
|
169
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notifier — subscribes to Redis pub/sub channels and bridges messages to Telegram.
|
|
3
|
+
*
|
|
4
|
+
* Channels:
|
|
5
|
+
* cca:notify:{namespace} — job completion notifications from cc-agent → forward to Telegram
|
|
6
|
+
* cca:chat:incoming:{namespace} — messages from the web UI → echo to Telegram + feed into Claude session
|
|
7
|
+
*
|
|
8
|
+
* All messages (Telegram incoming, Claude responses) are also written to:
|
|
9
|
+
* cca:chat:log:{namespace} — LPUSH + LTRIM 0 499 (last 500 messages)
|
|
10
|
+
* cca:chat:outgoing:{namespace} — PUBLISH for web UI to consume
|
|
11
|
+
*/
|
|
12
|
+
import { Redis } from "ioredis";
|
|
13
|
+
import TelegramBot from "node-telegram-bot-api";
|
|
14
|
+
export interface ChatMessage {
|
|
15
|
+
id: string;
|
|
16
|
+
source: "telegram" | "ui" | "claude";
|
|
17
|
+
role: "user" | "assistant";
|
|
18
|
+
content: string;
|
|
19
|
+
timestamp: string;
|
|
20
|
+
chatId: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Write a message to the chat log in Redis.
|
|
24
|
+
* Fire-and-forget — errors are logged but not thrown.
|
|
25
|
+
*/
|
|
26
|
+
export declare function writeChatLog(redis: Redis, namespace: string, msg: ChatMessage): void;
|
|
27
|
+
/**
|
|
28
|
+
* Start the notifier.
|
|
29
|
+
*
|
|
30
|
+
* @param bot - Telegram bot instance (for sending messages)
|
|
31
|
+
* @param chatId - Telegram chat ID to forward notifications to
|
|
32
|
+
* @param namespace - cc-agent namespace (used to build Redis channel names)
|
|
33
|
+
* @param redis - ioredis client in normal mode (will be duplicated for pub/sub)
|
|
34
|
+
* @param handleUserMessage - Optional callback to feed UI messages into the active Claude session
|
|
35
|
+
*/
|
|
36
|
+
export declare function startNotifier(bot: TelegramBot, chatId: number, namespace: string, redis: Redis, handleUserMessage?: (chatId: number, text: string) => void): void;
|
package/dist/notifier.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notifier — subscribes to Redis pub/sub channels and bridges messages to Telegram.
|
|
3
|
+
*
|
|
4
|
+
* Channels:
|
|
5
|
+
* cca:notify:{namespace} — job completion notifications from cc-agent → forward to Telegram
|
|
6
|
+
* cca:chat:incoming:{namespace} — messages from the web UI → echo to Telegram + feed into Claude session
|
|
7
|
+
*
|
|
8
|
+
* All messages (Telegram incoming, Claude responses) are also written to:
|
|
9
|
+
* cca:chat:log:{namespace} — LPUSH + LTRIM 0 499 (last 500 messages)
|
|
10
|
+
* cca:chat:outgoing:{namespace} — PUBLISH for web UI to consume
|
|
11
|
+
*/
|
|
12
|
+
function log(level, ...args) {
|
|
13
|
+
const fn = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
|
|
14
|
+
fn("[notifier]", ...args);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Write a message to the chat log in Redis.
|
|
18
|
+
* Fire-and-forget — errors are logged but not thrown.
|
|
19
|
+
*/
|
|
20
|
+
export function writeChatLog(redis, namespace, msg) {
|
|
21
|
+
const logKey = `cca:chat:log:${namespace}`;
|
|
22
|
+
const outKey = `cca:chat:outgoing:${namespace}`;
|
|
23
|
+
const payload = JSON.stringify(msg);
|
|
24
|
+
redis.lpush(logKey, payload).catch((err) => {
|
|
25
|
+
log("warn", "writeChatLog lpush failed:", err.message);
|
|
26
|
+
});
|
|
27
|
+
redis.ltrim(logKey, 0, 499).catch((err) => {
|
|
28
|
+
log("warn", "writeChatLog ltrim failed:", err.message);
|
|
29
|
+
});
|
|
30
|
+
redis.publish(outKey, payload).catch((err) => {
|
|
31
|
+
log("warn", "writeChatLog publish failed:", err.message);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Start the notifier.
|
|
36
|
+
*
|
|
37
|
+
* @param bot - Telegram bot instance (for sending messages)
|
|
38
|
+
* @param chatId - Telegram chat ID to forward notifications to
|
|
39
|
+
* @param namespace - cc-agent namespace (used to build Redis channel names)
|
|
40
|
+
* @param redis - ioredis client in normal mode (will be duplicated for pub/sub)
|
|
41
|
+
* @param handleUserMessage - Optional callback to feed UI messages into the active Claude session
|
|
42
|
+
*/
|
|
43
|
+
export function startNotifier(bot, chatId, namespace, redis, handleUserMessage) {
|
|
44
|
+
const sub = redis.duplicate();
|
|
45
|
+
sub.on("error", (err) => {
|
|
46
|
+
log("warn", "subscriber error:", err.message);
|
|
47
|
+
});
|
|
48
|
+
// cca:notify:{namespace} — forward job completion notifications to Telegram
|
|
49
|
+
sub.subscribe(`cca:notify:${namespace}`, (err) => {
|
|
50
|
+
if (err) {
|
|
51
|
+
log("error", `subscribe cca:notify:${namespace} failed:`, err.message);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
log("info", `subscribed to cca:notify:${namespace}`);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
// cca:chat:incoming:{namespace} — messages from UI
|
|
58
|
+
sub.subscribe(`cca:chat:incoming:${namespace}`, (err) => {
|
|
59
|
+
if (err) {
|
|
60
|
+
log("error", `subscribe cca:chat:incoming:${namespace} failed:`, err.message);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
log("info", `subscribed to cca:chat:incoming:${namespace}`);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
sub.on("message", (channel, message) => {
|
|
67
|
+
const notifyChannel = `cca:notify:${namespace}`;
|
|
68
|
+
const incomingChannel = `cca:chat:incoming:${namespace}`;
|
|
69
|
+
if (channel === notifyChannel) {
|
|
70
|
+
bot.sendMessage(chatId, message).catch((err) => {
|
|
71
|
+
log("warn", "sendMessage failed:", err.message);
|
|
72
|
+
});
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (channel === incomingChannel) {
|
|
76
|
+
let content = message;
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(message);
|
|
79
|
+
if (parsed.content)
|
|
80
|
+
content = parsed.content;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// raw string message — use as-is
|
|
84
|
+
}
|
|
85
|
+
// Echo to Telegram so the user sees UI messages in the chat
|
|
86
|
+
bot.sendMessage(chatId, `📱 [from UI]: ${content}`).catch((err) => {
|
|
87
|
+
log("warn", "sendMessage (UI echo) failed:", err.message);
|
|
88
|
+
});
|
|
89
|
+
// Log the incoming message
|
|
90
|
+
const inMsg = {
|
|
91
|
+
id: `ui-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
92
|
+
source: "ui",
|
|
93
|
+
role: "user",
|
|
94
|
+
content,
|
|
95
|
+
timestamp: new Date().toISOString(),
|
|
96
|
+
chatId,
|
|
97
|
+
};
|
|
98
|
+
writeChatLog(redis, namespace, inMsg);
|
|
99
|
+
// Feed into active Claude session as if user typed it
|
|
100
|
+
if (handleUserMessage) {
|
|
101
|
+
handleUserMessage(chatId, content);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
package/dist/tokens.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth token pool management.
|
|
3
|
+
*
|
|
4
|
+
* Supports CLAUDE_CODE_OAUTH_TOKENS (comma-separated list of tokens).
|
|
5
|
+
* Falls back to CLAUDE_CODE_OAUTH_TOKEN for single-token / backwards compat.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Load tokens from env vars. Called on startup; also re-callable in tests.
|
|
9
|
+
* Priority: CLAUDE_CODE_OAUTH_TOKENS > CLAUDE_CODE_OAUTH_TOKEN > (empty)
|
|
10
|
+
*/
|
|
11
|
+
export declare function loadTokens(): string[];
|
|
12
|
+
/** Returns the current active token, or empty string if none configured. */
|
|
13
|
+
export declare function getCurrentToken(): string;
|
|
14
|
+
/**
|
|
15
|
+
* Advance to the next token (wraps around).
|
|
16
|
+
* Returns the new current token.
|
|
17
|
+
*/
|
|
18
|
+
export declare function rotateToken(): string;
|
|
19
|
+
/** Zero-based index of the current token. */
|
|
20
|
+
export declare function getTokenIndex(): number;
|
|
21
|
+
/** Total number of tokens in the pool. */
|
|
22
|
+
export declare function getTokenCount(): number;
|
package/dist/tokens.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth token pool management.
|
|
3
|
+
*
|
|
4
|
+
* Supports CLAUDE_CODE_OAUTH_TOKENS (comma-separated list of tokens).
|
|
5
|
+
* Falls back to CLAUDE_CODE_OAUTH_TOKEN for single-token / backwards compat.
|
|
6
|
+
*/
|
|
7
|
+
let tokens = [];
|
|
8
|
+
let currentIndex = 0;
|
|
9
|
+
let initialized = false;
|
|
10
|
+
/**
|
|
11
|
+
* Load tokens from env vars. Called on startup; also re-callable in tests.
|
|
12
|
+
* Priority: CLAUDE_CODE_OAUTH_TOKENS > CLAUDE_CODE_OAUTH_TOKEN > (empty)
|
|
13
|
+
*/
|
|
14
|
+
export function loadTokens() {
|
|
15
|
+
const multi = process.env.CLAUDE_CODE_OAUTH_TOKENS;
|
|
16
|
+
if (multi) {
|
|
17
|
+
tokens = multi.split(",").map((t) => t.trim()).filter(Boolean);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
const single = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
21
|
+
tokens = single ? [single] : [];
|
|
22
|
+
}
|
|
23
|
+
currentIndex = 0;
|
|
24
|
+
initialized = true;
|
|
25
|
+
return tokens;
|
|
26
|
+
}
|
|
27
|
+
function ensureInitialized() {
|
|
28
|
+
if (!initialized)
|
|
29
|
+
loadTokens();
|
|
30
|
+
}
|
|
31
|
+
/** Returns the current active token, or empty string if none configured. */
|
|
32
|
+
export function getCurrentToken() {
|
|
33
|
+
ensureInitialized();
|
|
34
|
+
return tokens[currentIndex] ?? "";
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Advance to the next token (wraps around).
|
|
38
|
+
* Returns the new current token.
|
|
39
|
+
*/
|
|
40
|
+
export function rotateToken() {
|
|
41
|
+
ensureInitialized();
|
|
42
|
+
if (tokens.length === 0)
|
|
43
|
+
return "";
|
|
44
|
+
currentIndex = (currentIndex + 1) % tokens.length;
|
|
45
|
+
return tokens[currentIndex];
|
|
46
|
+
}
|
|
47
|
+
/** Zero-based index of the current token. */
|
|
48
|
+
export function getTokenIndex() {
|
|
49
|
+
ensureInitialized();
|
|
50
|
+
return currentIndex;
|
|
51
|
+
}
|
|
52
|
+
/** Total number of tokens in the pool. */
|
|
53
|
+
export function getTokenCount() {
|
|
54
|
+
ensureInitialized();
|
|
55
|
+
return tokens.length;
|
|
56
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function detectUsageLimit(text) {
|
|
2
|
+
const lower = text.toLowerCase();
|
|
3
|
+
if (lower.includes('extra usage') ||
|
|
4
|
+
lower.includes('usage has been disabled') ||
|
|
5
|
+
lower.includes('billing_error') ||
|
|
6
|
+
lower.includes('usage limit')) {
|
|
7
|
+
const wake = nextHourBoundary() + 5 * 60 * 1000;
|
|
8
|
+
return {
|
|
9
|
+
detected: true,
|
|
10
|
+
reason: 'usage_exhausted',
|
|
11
|
+
retryAfterMs: wake - Date.now(),
|
|
12
|
+
humanMessage: `⏸ Claude usage limit reached. Will auto-resume at ${new Date(wake).toUTCString()}. I'll message you when it's back.`,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
if (lower.includes('rate limit') || lower.includes('overloaded')) {
|
|
16
|
+
return {
|
|
17
|
+
detected: true,
|
|
18
|
+
reason: 'rate_limit',
|
|
19
|
+
retryAfterMs: 2 * 60 * 1000,
|
|
20
|
+
humanMessage: `⏸ Rate limited. Retrying in 2 minutes...`,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
return { detected: false, reason: 'rate_limit', retryAfterMs: 0, humanMessage: '' };
|
|
24
|
+
}
|
|
25
|
+
function nextHourBoundary() {
|
|
26
|
+
const d = new Date();
|
|
27
|
+
d.setHours(d.getHours() + 1, 0, 0, 0);
|
|
28
|
+
return d.getTime();
|
|
29
|
+
}
|
package/dist/voice.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voice message transcription via whisper.cpp.
|
|
3
|
+
* Flow: Telegram OGG → ffmpeg convert to 16kHz WAV → whisper-cpp → text
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Transcribe a voice message from a Telegram file URL.
|
|
7
|
+
* Returns the transcribed text, or throws if whisper/ffmpeg not available.
|
|
8
|
+
*/
|
|
9
|
+
export declare function transcribeVoice(fileUrl: string): Promise<string>;
|
|
10
|
+
/**
|
|
11
|
+
* Check if voice transcription is available on this system.
|
|
12
|
+
*/
|
|
13
|
+
export declare function isVoiceAvailable(): boolean;
|
package/dist/voice.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voice message transcription via whisper.cpp.
|
|
3
|
+
* Flow: Telegram OGG → ffmpeg convert to 16kHz WAV → whisper-cpp → text
|
|
4
|
+
*/
|
|
5
|
+
import { execFile } from "child_process";
|
|
6
|
+
import { promisify } from "util";
|
|
7
|
+
import { existsSync } from "fs";
|
|
8
|
+
import { unlink } from "fs/promises";
|
|
9
|
+
import { tmpdir } from "os";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
import https from "https";
|
|
12
|
+
import http from "http";
|
|
13
|
+
import { createWriteStream } from "fs";
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
// Whisper model — small.en is fast and accurate enough for commands
|
|
16
|
+
// Falls back to base.en if small not found
|
|
17
|
+
const WHISPER_MODELS = [
|
|
18
|
+
"/opt/homebrew/share/whisper-cpp/ggml-small.en.bin",
|
|
19
|
+
"/opt/homebrew/share/whisper-cpp/ggml-small.bin",
|
|
20
|
+
"/opt/homebrew/share/whisper-cpp/ggml-base.en.bin",
|
|
21
|
+
"/opt/homebrew/share/whisper-cpp/ggml-base.bin",
|
|
22
|
+
// user-local
|
|
23
|
+
`${process.env.HOME}/.local/share/whisper-cpp/ggml-small.en.bin`,
|
|
24
|
+
`${process.env.HOME}/.local/share/whisper-cpp/ggml-base.en.bin`,
|
|
25
|
+
];
|
|
26
|
+
const WHISPER_BIN_CANDIDATES = [
|
|
27
|
+
"/opt/homebrew/bin/whisper-cli", // whisper-cpp brew formula installs as whisper-cli
|
|
28
|
+
"/opt/homebrew/bin/whisper-cpp",
|
|
29
|
+
"/usr/local/bin/whisper-cli",
|
|
30
|
+
"/usr/local/bin/whisper-cpp",
|
|
31
|
+
"/opt/homebrew/bin/whisper",
|
|
32
|
+
];
|
|
33
|
+
const FFMPEG_CANDIDATES = [
|
|
34
|
+
"/opt/homebrew/bin/ffmpeg",
|
|
35
|
+
"/usr/local/bin/ffmpeg",
|
|
36
|
+
"/usr/bin/ffmpeg",
|
|
37
|
+
];
|
|
38
|
+
function findBin(candidates) {
|
|
39
|
+
for (const p of candidates) {
|
|
40
|
+
if (existsSync(p))
|
|
41
|
+
return p;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
function findModel() {
|
|
46
|
+
for (const p of WHISPER_MODELS) {
|
|
47
|
+
if (existsSync(p))
|
|
48
|
+
return p;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
function downloadFile(url, dest) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const file = createWriteStream(dest);
|
|
55
|
+
const getter = url.startsWith("https") ? https : http;
|
|
56
|
+
getter.get(url, (res) => {
|
|
57
|
+
if (res.statusCode !== 200) {
|
|
58
|
+
reject(new Error(`HTTP ${res.statusCode} downloading ${url}`));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
res.pipe(file);
|
|
62
|
+
file.on("finish", () => file.close(() => resolve()));
|
|
63
|
+
file.on("error", reject);
|
|
64
|
+
}).on("error", reject);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Transcribe a voice message from a Telegram file URL.
|
|
69
|
+
* Returns the transcribed text, or throws if whisper/ffmpeg not available.
|
|
70
|
+
*/
|
|
71
|
+
export async function transcribeVoice(fileUrl) {
|
|
72
|
+
const whisperBin = findBin(WHISPER_BIN_CANDIDATES);
|
|
73
|
+
if (!whisperBin)
|
|
74
|
+
throw new Error("whisper-cpp not found — install with: brew install whisper-cpp");
|
|
75
|
+
const ffmpegBin = findBin(FFMPEG_CANDIDATES);
|
|
76
|
+
if (!ffmpegBin)
|
|
77
|
+
throw new Error("ffmpeg not found — install with: brew install ffmpeg");
|
|
78
|
+
const model = findModel();
|
|
79
|
+
if (!model)
|
|
80
|
+
throw new Error("No whisper model found — run: whisper-cpp-download-ggml-model small.en");
|
|
81
|
+
const tmp = join(tmpdir(), `cc-tg-voice-${Date.now()}`);
|
|
82
|
+
const oggPath = `${tmp}.ogg`;
|
|
83
|
+
const wavPath = `${tmp}.wav`;
|
|
84
|
+
try {
|
|
85
|
+
// 1. Download OGG from Telegram
|
|
86
|
+
await downloadFile(fileUrl, oggPath);
|
|
87
|
+
// 2. Convert OGG → 16kHz mono WAV (whisper requirement)
|
|
88
|
+
await execFileAsync(ffmpegBin, [
|
|
89
|
+
"-y", "-i", oggPath,
|
|
90
|
+
"-ar", "16000",
|
|
91
|
+
"-ac", "1",
|
|
92
|
+
"-c:a", "pcm_s16le",
|
|
93
|
+
wavPath,
|
|
94
|
+
]);
|
|
95
|
+
// 3. Run whisper-cpp
|
|
96
|
+
const { stdout } = await execFileAsync(whisperBin, [
|
|
97
|
+
"-m", model,
|
|
98
|
+
"-f", wavPath,
|
|
99
|
+
"--no-timestamps",
|
|
100
|
+
"-l", "auto",
|
|
101
|
+
"--output-txt",
|
|
102
|
+
]);
|
|
103
|
+
// whisper outputs to stdout — strip leading/trailing whitespace and [BLANK_AUDIO] artifacts
|
|
104
|
+
const text = stdout
|
|
105
|
+
.replace(/\[BLANK_AUDIO\]/gi, "")
|
|
106
|
+
.replace(/\[.*?\]/g, "") // remove timestamp artifacts
|
|
107
|
+
.trim();
|
|
108
|
+
return text || "[empty transcription]";
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
// Cleanup temp files
|
|
112
|
+
await unlink(oggPath).catch(() => { });
|
|
113
|
+
await unlink(wavPath).catch(() => { });
|
|
114
|
+
await unlink(`${wavPath}.txt`).catch(() => { });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Check if voice transcription is available on this system.
|
|
119
|
+
*/
|
|
120
|
+
export function isVoiceAvailable() {
|
|
121
|
+
return (findBin(WHISPER_BIN_CANDIDATES) !== null &&
|
|
122
|
+
findBin(FFMPEG_CANDIDATES) !== null &&
|
|
123
|
+
findModel() !== null);
|
|
124
|
+
}
|