pi-telegram-notifier 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/LICENSE +21 -0
- package/README.md +50 -0
- package/docs/providers.md +22 -0
- package/extensions/index.ts +30 -0
- package/package.json +14 -0
- package/src/notification.js +39 -0
- package/src/quota.js +101 -0
- package/src/telegram.js +82 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Oreste Acacia
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# pi-telegram-notifier
|
|
2
|
+
|
|
3
|
+
Send the **final** response of each Pi agent run to a paired private Telegram chat. Each notification contains a UTC timestamp, provider, full response text, and the **actual** remaining provider balance when it can be queried. Telegram metadata (provider and quota) is in English; the agent's response is forwarded verbatim in its original language. Intermediate tool calls do not trigger messages.
|
|
4
|
+
|
|
5
|
+
## Install and configure
|
|
6
|
+
|
|
7
|
+
Requires Node.js 22.19+ and [Pi](https://pi.dev). Create a Telegram bot using [@BotFather](https://t.me/BotFather), then set its token **outside the repository** before starting Pi:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
export PI_TELEGRAM_BOT_TOKEN='your-bot-token'
|
|
11
|
+
pi -e . # test this checkout as a Pi package
|
|
12
|
+
# Later, after public release: pi install npm:pi-telegram-notifier
|
|
13
|
+
# Or: pi install git:github.com/Gybra/pi-telegram-notifier
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
In Pi run `/telegram-pair`. Within five minutes send **`/start <code>`** to your bot in a private Telegram chat using the displayed one-time code. The extension stores only the numeric chat ID in `~/.pi/agent/pi-telegram-notifier.json` (or `$PI_CODING_AGENT_DIR/pi-telegram-notifier.json`), with mode 0600. To switch chats, run `/telegram-pair` again; the existing chat stays paired if pairing fails. The bot needs to receive messages via `getUpdates` during pairing: a webhook or another updates consumer must be disconnected first. There is no always-on polling after pairing.
|
|
17
|
+
|
|
18
|
+
**Privacy:** final agent responses are sent in full to Telegram. They can contain sensitive text; only use this package if that is acceptable for your projects and Telegram chat. Bot and provider tokens are not stored in the package or sent as message text. Telegram Bot API uses the bot token in the request URL; do not log outgoing URLs. Keep your shell environment and Pi credentials private. A lost or compromised Telegram bot token must be revoked with BotFather.
|
|
19
|
+
|
|
20
|
+
## Quota coverage
|
|
21
|
+
|
|
22
|
+
The **quota/credit** field supports these Pi provider IDs (using the credential Pi resolves for the active model):
|
|
23
|
+
|
|
24
|
+
| Pi provider ID | Mode | Required credential | What is shown |
|
|
25
|
+
| --- | --- | --- | --- |
|
|
26
|
+
| `openai-codex` | Subscription | ChatGPT OAuth | Remaining 5h / 7d usage-window percentages; [official Codex client endpoint](https://github.com/openai/codex/blob/main/codex-rs/backend-client/src/client/rate_limit_resets.rs). |
|
|
27
|
+
| `anthropic` | Subscription | Claude OAuth | Remaining 5h / 7d usage-window percentages; [private OAuth endpoint](https://github.com/openclaw/openclaw/blob/main/src/infra/provider-usage.fetch.claude.ts). |
|
|
28
|
+
| `xai` | Subscription | Grok OAuth | Remaining weekly shared Grok credits percentage, **not** prepaid API credit; [official Grok client endpoint](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-shell/src/extensions/billing.rs). |
|
|
29
|
+
| `zai` | Subscription (Coding Plan) | Z.ai Coding Plan key | Remaining 5h **model** quota percentage; [Z.ai usage plugin endpoint](https://github.com/zai-org/zai-coding-plugins/blob/main/plugins/glm-plan-usage/skills/usage-query-skill/scripts/query-usage.mjs). Not the separate MCP tool quota. |
|
|
30
|
+
| `zai-coding-cn` | Subscription (Coding Plan) | China Coding Plan key | Same model quota on `open.bigmodel.cn`. |
|
|
31
|
+
| `openrouter` | API credit | **Management key** | Remaining account credits in USD; ordinary inference keys cannot access the credits endpoint (HTTP 403). Per-key spending limits are not account credit. |
|
|
32
|
+
| `deepseek` | API credit | API key | Available CNY/USD balance via `/user/balance`. |
|
|
33
|
+
| `moonshotai` | API credit | API key | Available USD balance via `/v1/users/me/balance`. |
|
|
34
|
+
| `moonshotai-cn` | API credit | API key | Available CNY balance via `/v1/users/me/balance`. |
|
|
35
|
+
|
|
36
|
+
**Not supported as API credit:** an `openai` API key does not expose a verified remaining-balance endpoint; an `anthropic` API key does not expose its subscription quota **or** a verified remaining API credit; an `xai` API key does not expose Grok subscription quota or the prepaid API balance (the latter requires a separate management key and team ID); Z.ai pay-as-you-go keys may have no Coding Plan quota. None of the listed providers currently supports both subscription quota **and** API credit through this extension. Usage/cost reports and spending caps are **not** remaining credit.
|
|
37
|
+
|
|
38
|
+
Pi resolves and refreshes provider credentials, including those saved in its `auth.json`; this extension does **not** read that file. The Codex, Claude, Grok and Z.ai quota endpoints above are not guaranteed stable public APIs. All other providers, custom/proxied endpoints, inaccessible credentials, and failed or malformed lookups show `Quota unavailable` **without blocking the notification**. Quota percentage is not inferred from context-window usage, token usage, or request rate limits. See [the 41-provider inventory](docs/providers.md) for sources and limitations.
|
|
39
|
+
|
|
40
|
+
Telegram has a message-length limit; long responses are delivered in consecutive plain-text chunks. If delivery fails, Pi reports a generic failure without leaking message or credentials. Network requests have timeouts; Pi remains usable.
|
|
41
|
+
|
|
42
|
+
## Develop and release
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
npm test
|
|
46
|
+
npm pack --dry-run --json
|
|
47
|
+
pi -e . --help
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Read [AGENTS.md](AGENTS.md) before editing. Open contributions with tests for behavior changes and official provider documentation for balance integrations. MIT licensed; see [LICENSE](LICENSE). CI runs tests and packaging checks. Publishing is deliberately manual: create the public GitHub repository, then publish the npm package with the `pi-package` keyword to make it eligible for the [Pi package gallery](https://pi.dev/packages). No action here pushes, publishes, or handles real credentials.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Provider balance coverage
|
|
2
|
+
|
|
3
|
+
Inventory of the 41 built-in provider IDs in the installed Pi model catalog (`@earendil-works/pi-ai`), checked against provider API documentation. This is **account balance / subscription quota**, not the model's context window, request rate limit, past spending, or an API-key spending cap. Pi supplies the active model's credential, whether configured in `auth.json`, environment, or through OAuth. Third-party/custom providers are unknown and display `Quota unavailable`.
|
|
4
|
+
|
|
5
|
+
| Pi provider ID | Status | Official reference |
|
|
6
|
+
| --- | --- | --- |
|
|
7
|
+
| `openrouter` | Account credit = `total_credits - total_usage` (USD), **only if Pi's credential is an OpenRouter management key**; normal inference keys receive HTTP 403 and show unavailable. | [Get remaining credits](https://openrouter.ai/docs/api/api-reference/credits/get-remaining-credits), `GET https://openrouter.ai/api/v1/credits` |
|
|
8
|
+
| `openai-codex` | Remaining subscription quota for the primary and secondary usage windows (typically 5h / 7d), from `100 - used_percent`; Pi's OAuth token and ChatGPT account ID are required. This backend route is used by the official Codex client but is **not a stable public API**. | [Official Codex client usage request](https://github.com/openai/codex/blob/main/codex-rs/backend-client/src/client/rate_limit_resets.rs), [response fields](https://github.com/openai/codex/blob/main/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_details.rs), `GET https://chatgpt.com/backend-api/wham/usage` |
|
|
9
|
+
| `deepseek` | Available balance per currency (CNY and/or USD); no currency conversion. | [Get User Balance](https://api-docs.deepseek.com/api/get-user-balance), `GET https://api.deepseek.com/user/balance` |
|
|
10
|
+
| `moonshotai` | Available balance, USD. | [Balance](https://platform.moonshot.ai/docs/api/balance), `GET https://api.moonshot.ai/v1/users/me/balance` |
|
|
11
|
+
| `moonshotai-cn` | Available balance, CNY. | [Balance](https://platform.moonshot.cn/docs/api/balance), `GET https://api.moonshot.cn/v1/users/me/balance` |
|
|
12
|
+
| `zai` | Remaining 5-hour Coding Plan model quota from `100 - percentage` for `TOKENS_LIMIT` only; pay-as-you-go keys or unknown plan data show unavailable. | [Official Z.ai usage query script](https://github.com/zai-org/zai-coding-plugins/blob/main/plugins/glm-plan-usage/skills/usage-query-skill/scripts/query-usage.mjs), `GET https://api.z.ai/api/monitor/usage/quota/limit` (not a stable public API) |
|
|
13
|
+
| `zai-coding-cn` | Same Coding Plan model quota for the China host; not the MCP tool quota. | [Official Z.ai usage query script](https://github.com/zai-org/zai-coding-plugins/blob/main/plugins/glm-plan-usage/skills/usage-query-skill/scripts/query-usage.mjs), `GET https://open.bigmodel.cn/api/monitor/usage/quota/limit` (not a stable public API) |
|
|
14
|
+
| `anthropic` | Remaining 5h and/or 7d Claude **subscription** allowance with Pi OAuth only; regular API keys show unavailable. | [OAuth usage client](https://github.com/openclaw/openclaw/blob/main/src/infra/provider-usage.fetch.claude.ts), `GET https://api.anthropic.com/api/oauth/usage` (private endpoint) |
|
|
15
|
+
| `xai` | Remaining **weekly Grok subscription** credits with Pi OAuth only. The per-product percentages are not the shared pool; xAI API prepaid credit is a different quantity requiring a management key. | [Official Grok billing client](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-shell/src/extensions/billing.rs), `GET https://cli-chat-proxy.grok.com/v1/billing?format=credits` (private endpoint); [API management billing](https://docs.x.ai/developers/rest-api-reference/management/billing) |
|
|
16
|
+
| `amazon-bedrock`, `azure-openai-responses`, `cloudflare-ai-gateway`, `cloudflare-workers-ai`, `google-vertex`, `radius`, `vercel-ai-gateway` | Cloud/gateway billing belongs to separate accounts/credentials; no verified remaining plan balance endpoint with Pi's inference credential. | Provider-specific cloud billing tools, not Pi model API keys. |
|
|
17
|
+
| `openai`, `github-copilot` | Subscription balance not exposed through a verified quota endpoint accessible with Pi's resolved inference credential. Admin usage/spending APIs are **not** plan remainder. | [OpenAI organization costs](https://platform.openai.com/docs/api-reference/organization/costs) |
|
|
18
|
+
| `fireworks`, `together` | Documented billing usage / quotas are past spend or request limits, not an available credit balance verified for inference keys. | [Fireworks billing summary](https://docs.fireworks.ai/api-reference/get-billing-summary), [Together billing usage](https://docs.together.ai/reference/billing-usage) |
|
|
19
|
+
| `ant-ling`, `baseten`, `cerebras`, `google`, `groq`, `huggingface`, `kimi-coding`, `meta`, `minimax`, `minimax-cn`, `mistral` | No verified official endpoint returning remaining allowance with Pi's inference credential. | Check provider billing documentation before adding integrations. |
|
|
20
|
+
| `nvidia`, `opencode`, `opencode-go`, `qwen-token-plan`, `qwen-token-plan-cn`, `qwen-token-plan-individual`, `xiaomi`, `xiaomi-token-plan-ams`, `xiaomi-token-plan-cn`, `xiaomi-token-plan-sgp` | No verified official endpoint returning remaining allowance with Pi's inference credential; management/billing credentials, if available, are distinct. | Check provider billing documentation before adding integrations. |
|
|
21
|
+
|
|
22
|
+
An HTTP 401/403, invalid response or missing credential always yields `Quota unavailable` without suppressing the notification. OpenRouter's `/api/v1/key` reports a **per-key** limit, not remaining account credit: deliberately not used. This inventory is for the Pi catalog present at implementation time; new providers require fresh verification.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import { pairChat, saveChatId } from '../src/telegram.js';
|
|
6
|
+
import { registerNotifications } from '../src/notification.js';
|
|
7
|
+
|
|
8
|
+
const agentDir = () => process.env.PI_CODING_AGENT_DIR || join(homedir(), '.pi', 'agent');
|
|
9
|
+
|
|
10
|
+
export default function (pi: ExtensionAPI) {
|
|
11
|
+
registerNotifications(pi, agentDir(), process.env.PI_TELEGRAM_BOT_TOKEN);
|
|
12
|
+
pi.registerCommand('telegram-pair', {
|
|
13
|
+
description: 'Pair a private Telegram chat with this Pi installation',
|
|
14
|
+
handler: async (_args, ctx) => {
|
|
15
|
+
const token = process.env.PI_TELEGRAM_BOT_TOKEN;
|
|
16
|
+
if (!token) {
|
|
17
|
+
ctx.ui.notify('Set PI_TELEGRAM_BOT_TOKEN before starting Pi', 'error');
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const code = randomBytes(16).toString('hex');
|
|
21
|
+
ctx.ui.notify(`Send /start ${code} to your Telegram bot within 5 minutes`, 'info');
|
|
22
|
+
try {
|
|
23
|
+
saveChatId(agentDir(), await pairChat(token, code));
|
|
24
|
+
ctx.ui.notify('Private Telegram chat paired', 'info');
|
|
25
|
+
} catch (error) {
|
|
26
|
+
ctx.ui.notify(error instanceof Error ? error.message : 'Telegram pairing failed', 'error');
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-telegram-notifier",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Telegram notifications for final Pi agent responses and provider balances",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": { "node": ">=22.19.0" },
|
|
8
|
+
"keywords": ["pi-package", "pi", "telegram", "notifications"],
|
|
9
|
+
"files": ["extensions", "src", "README.md", "LICENSE", "docs/providers.md"],
|
|
10
|
+
"scripts": { "test": "node --test" },
|
|
11
|
+
"pi": { "extensions": ["./extensions/index.ts"] },
|
|
12
|
+
"peerDependencies": { "@earendil-works/pi-coding-agent": "*" },
|
|
13
|
+
"repository": { "type": "git", "url": "git+https://github.com/Gybra/pi-telegram-notifier.git" }
|
|
14
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { loadChatId, sendTelegram } from './telegram.js';
|
|
2
|
+
import { getQuota } from './quota.js';
|
|
3
|
+
|
|
4
|
+
export function finalResponse(branch, startLeafId) {
|
|
5
|
+
const index = startLeafId === null ? -1 : branch.findIndex((entry) => entry.id === startLeafId);
|
|
6
|
+
if (index < 0 && startLeafId !== null) return undefined;
|
|
7
|
+
const latest = branch.slice(index + 1).filter((entry) => entry.type === 'message').at(-1)?.message;
|
|
8
|
+
if (latest?.role !== 'assistant' || !['stop', 'length'].includes(latest.stopReason)) return undefined;
|
|
9
|
+
const text = latest.content.filter((item) => item.type === 'text').map((item) => item.text).join('');
|
|
10
|
+
if (!text) return undefined;
|
|
11
|
+
return { text, provider: latest.provider, modelId: latest.model };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function formatNotification(timestamp, response, quota) {
|
|
15
|
+
return `${timestamp}\nProvider: ${response.provider}\n${quota}\n\n${response.text}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function registerNotifications(pi, agentDir, token, fetchImpl = fetch) {
|
|
19
|
+
let startLeafId;
|
|
20
|
+
pi.on('agent_start', (_event, ctx) => {
|
|
21
|
+
startLeafId = ctx.sessionManager.getLeafId();
|
|
22
|
+
});
|
|
23
|
+
pi.on('agent_settled', async (_event, ctx) => {
|
|
24
|
+
if (!token || startLeafId === undefined) return;
|
|
25
|
+
const response = finalResponse(ctx.sessionManager.getBranch(), startLeafId);
|
|
26
|
+
startLeafId = undefined;
|
|
27
|
+
const chatId = loadChatId(agentDir);
|
|
28
|
+
if (!chatId || !response) return;
|
|
29
|
+
try {
|
|
30
|
+
const model = ctx.modelRegistry?.find(response.provider, response.modelId);
|
|
31
|
+
const quota = await getQuota(model, ctx.modelRegistry, fetchImpl);
|
|
32
|
+
await sendTelegram(token, chatId,
|
|
33
|
+
formatNotification(new Date().toISOString(), response, quota), fetchImpl);
|
|
34
|
+
} catch {
|
|
35
|
+
if (ctx.hasUI) ctx.ui.notify('Telegram delivery failed; check the bot connection', 'error');
|
|
36
|
+
else console.error('Telegram delivery failed; check the bot connection');
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
package/src/quota.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const UNAVAILABLE = 'Quota unavailable';
|
|
2
|
+
|
|
3
|
+
const providers = {
|
|
4
|
+
openrouter: ['https://openrouter.ai', 'https://openrouter.ai/api/v1/credits'],
|
|
5
|
+
'openai-codex': ['https://chatgpt.com', 'https://chatgpt.com/backend-api/wham/usage'],
|
|
6
|
+
deepseek: ['https://api.deepseek.com', 'https://api.deepseek.com/user/balance'],
|
|
7
|
+
moonshotai: ['https://api.moonshot.ai', 'https://api.moonshot.ai/v1/users/me/balance'],
|
|
8
|
+
'moonshotai-cn': ['https://api.moonshot.cn', 'https://api.moonshot.cn/v1/users/me/balance'],
|
|
9
|
+
zai: ['https://api.z.ai', 'https://api.z.ai/api/monitor/usage/quota/limit'],
|
|
10
|
+
'zai-coding-cn': ['https://open.bigmodel.cn', 'https://open.bigmodel.cn/api/monitor/usage/quota/limit'],
|
|
11
|
+
anthropic: ['https://api.anthropic.com', 'https://api.anthropic.com/api/oauth/usage'],
|
|
12
|
+
xai: ['https://api.x.ai', 'https://cli-chat-proxy.grok.com/v1/billing?format=credits'],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function remainingPercent(used) {
|
|
16
|
+
return typeof used === 'number' && Number.isFinite(used) && used >= 0 && used <= 100 ? 100 - used : undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function getQuota(model, modelRegistry, fetchImpl = fetch) {
|
|
20
|
+
const provider = model && providers[model.provider];
|
|
21
|
+
if (!provider) return UNAVAILABLE;
|
|
22
|
+
try {
|
|
23
|
+
if (new URL(model.baseUrl).origin !== provider[0]) return UNAVAILABLE;
|
|
24
|
+
if (['anthropic', 'xai'].includes(model.provider) && !modelRegistry.isUsingOAuth(model)) return UNAVAILABLE;
|
|
25
|
+
const auth = await modelRegistry.getApiKeyAndHeaders(model);
|
|
26
|
+
if (!auth.ok || !auth.apiKey || (auth.baseUrl && new URL(auth.baseUrl).origin !== provider[0])) return UNAVAILABLE;
|
|
27
|
+
const headers = { Authorization: ['zai', 'zai-coding-cn'].includes(model.provider) ? auth.apiKey : `Bearer ${auth.apiKey}` };
|
|
28
|
+
if (model.provider === 'anthropic') {
|
|
29
|
+
headers['anthropic-beta'] = 'oauth-2025-04-20';
|
|
30
|
+
headers['anthropic-version'] = '2023-06-01';
|
|
31
|
+
}
|
|
32
|
+
if (model.provider === 'openai-codex') {
|
|
33
|
+
const payload = JSON.parse(Buffer.from(auth.apiKey.split('.')[1], 'base64url').toString('utf8'));
|
|
34
|
+
const accountId = payload['https://api.openai.com/auth']?.chatgpt_account_id;
|
|
35
|
+
if (typeof accountId !== 'string' || !accountId) return UNAVAILABLE;
|
|
36
|
+
headers['ChatGPT-Account-Id'] = accountId;
|
|
37
|
+
}
|
|
38
|
+
const response = await fetchImpl(provider[1], {
|
|
39
|
+
headers,
|
|
40
|
+
signal: AbortSignal.timeout(10_000),
|
|
41
|
+
});
|
|
42
|
+
if (!response.ok) return UNAVAILABLE;
|
|
43
|
+
const data = await response.json();
|
|
44
|
+
if (['zai', 'zai-coding-cn'].includes(model.provider)) {
|
|
45
|
+
if (data.success === false || ![undefined, 0, 200].includes(data.code)) return UNAVAILABLE;
|
|
46
|
+
const used = data.data?.limits?.find((limit) => limit.type === 'TOKENS_LIMIT')?.percentage;
|
|
47
|
+
const remaining = remainingPercent(used);
|
|
48
|
+
return remaining === undefined ? UNAVAILABLE : `Remaining quota: 5h ${remaining}%`;
|
|
49
|
+
}
|
|
50
|
+
if (model.provider === 'anthropic') {
|
|
51
|
+
const windows = [['five_hour', '5h'], ['seven_day', '7d']]
|
|
52
|
+
.map(([key, label]) => {
|
|
53
|
+
const remaining = remainingPercent(data[key]?.utilization);
|
|
54
|
+
return remaining === undefined ? undefined : `${label} ${remaining}%`;
|
|
55
|
+
}).filter(Boolean);
|
|
56
|
+
return windows.length ? `Remaining quota: ${windows.join(', ')}` : UNAVAILABLE;
|
|
57
|
+
}
|
|
58
|
+
if (model.provider === 'xai') {
|
|
59
|
+
const config = data.config;
|
|
60
|
+
if (config?.currentPeriod?.type !== 'USAGE_PERIOD_TYPE_WEEKLY') return UNAVAILABLE;
|
|
61
|
+
let remaining = remainingPercent(config.creditUsagePercent);
|
|
62
|
+
if (remaining === undefined && config.creditUsagePercent === undefined) {
|
|
63
|
+
const { currentPeriod, productUsage } = config;
|
|
64
|
+
if (Date.parse(currentPeriod.start) < Date.parse(currentPeriod.end) &&
|
|
65
|
+
(productUsage === undefined || (Array.isArray(productUsage) && productUsage.every((item) => item?.usagePercent === 0)))) remaining = 100;
|
|
66
|
+
}
|
|
67
|
+
return remaining === undefined ? UNAVAILABLE : `Remaining quota: 7d ${remaining}%`;
|
|
68
|
+
}
|
|
69
|
+
if (model.provider === 'openai-codex') {
|
|
70
|
+
const windows = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window].filter(Boolean);
|
|
71
|
+
if (!windows.length) return UNAVAILABLE;
|
|
72
|
+
const remaining = windows.map(({ used_percent: used, limit_window_seconds: seconds }) => {
|
|
73
|
+
if (!Number.isFinite(used) || used < 0 || used > 100 || !Number.isSafeInteger(seconds) || seconds <= 0) return undefined;
|
|
74
|
+
const period = seconds % 86400 === 0 ? `${seconds / 86400}d` : seconds % 3600 === 0 ? `${seconds / 3600}h` : `${seconds / 60}m`;
|
|
75
|
+
return `${period} ${100 - used}%`;
|
|
76
|
+
});
|
|
77
|
+
return remaining.every(Boolean) ? `Remaining quota: ${remaining.join(', ')}` : UNAVAILABLE;
|
|
78
|
+
}
|
|
79
|
+
if (model.provider === 'openrouter') {
|
|
80
|
+
const total = data.data?.total_credits;
|
|
81
|
+
const used = data.data?.total_usage;
|
|
82
|
+
if (typeof total !== 'number' || typeof used !== 'number' ||
|
|
83
|
+
!Number.isFinite(total) || !Number.isFinite(used) || total < 0 || used < 0 || used > total) return UNAVAILABLE;
|
|
84
|
+
return `Remaining credits: ${(total - used).toFixed(6).replace(/\.?0+$/, '')} USD`;
|
|
85
|
+
}
|
|
86
|
+
if (model.provider === 'deepseek') {
|
|
87
|
+
if (!Array.isArray(data.balance_infos) || !data.balance_infos.length) return UNAVAILABLE;
|
|
88
|
+
const balances = data.balance_infos.map(({ currency, total_balance }) => {
|
|
89
|
+
if (!['CNY', 'USD'].includes(currency) || typeof total_balance !== 'string' ||
|
|
90
|
+
!/^\d+(?:\.\d+)?$/.test(total_balance)) return undefined;
|
|
91
|
+
return `${total_balance} ${currency}`;
|
|
92
|
+
});
|
|
93
|
+
return balances.every(Boolean) ? `Remaining balance: ${balances.join(', ')}` : UNAVAILABLE;
|
|
94
|
+
}
|
|
95
|
+
const amount = data.data?.available_balance;
|
|
96
|
+
if (data.status !== true || data.code !== 0 || typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0) return UNAVAILABLE;
|
|
97
|
+
return `Remaining balance: ${amount} ${model.provider === 'moonshotai-cn' ? 'CNY' : 'USD'}`;
|
|
98
|
+
} catch {
|
|
99
|
+
return UNAVAILABLE;
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/telegram.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const stateFile = (agentDir) => join(agentDir, 'pi-telegram-notifier.json');
|
|
6
|
+
|
|
7
|
+
export function loadChatId(agentDir) {
|
|
8
|
+
try {
|
|
9
|
+
const id = JSON.parse(readFileSync(stateFile(agentDir), 'utf8')).chatId;
|
|
10
|
+
return Number.isSafeInteger(id) && id > 0 ? id : undefined;
|
|
11
|
+
} catch {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function saveChatId(agentDir, chatId) {
|
|
17
|
+
if (!Number.isSafeInteger(chatId) || chatId <= 0) throw new Error('Invalid private chat ID');
|
|
18
|
+
mkdirSync(agentDir, { recursive: true, mode: 0o700 });
|
|
19
|
+
const temporary = `${stateFile(agentDir)}.${randomBytes(8).toString('hex')}`;
|
|
20
|
+
writeFileSync(temporary, JSON.stringify({ chatId }), { mode: 0o600, flag: 'wx' });
|
|
21
|
+
renameSync(temporary, stateFile(agentDir));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function pairChat(token, code, fetchImpl = fetch, now = Date.now) {
|
|
25
|
+
const expires = now() + 300_000;
|
|
26
|
+
let offset = 0;
|
|
27
|
+
while (now() < expires) {
|
|
28
|
+
let response;
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(`https://api.telegram.org/bot${token}/getUpdates`);
|
|
31
|
+
url.searchParams.set('timeout', '20');
|
|
32
|
+
url.searchParams.set('offset', String(offset));
|
|
33
|
+
response = await fetchImpl(url, { signal: AbortSignal.timeout(25_000) });
|
|
34
|
+
} catch {
|
|
35
|
+
throw new Error('Telegram pairing request failed');
|
|
36
|
+
}
|
|
37
|
+
if (response.status === 409) throw new Error('Telegram webhook or another getUpdates consumer is active');
|
|
38
|
+
if (!response.ok) throw new Error(`Telegram pairing failed (HTTP ${response.status})`);
|
|
39
|
+
let data;
|
|
40
|
+
try { data = await response.json(); } catch { throw new Error('Invalid Telegram pairing response'); }
|
|
41
|
+
if (!data.ok || !Array.isArray(data.result)) throw new Error('Invalid Telegram pairing response');
|
|
42
|
+
for (const update of data.result) {
|
|
43
|
+
offset = Math.max(offset, update.update_id + 1);
|
|
44
|
+
if (now() < expires && update.message?.chat?.type === 'private' &&
|
|
45
|
+
update.message.text === `/start ${code}` &&
|
|
46
|
+
Number.isSafeInteger(update.message.chat.id) && update.message.chat.id > 0) {
|
|
47
|
+
return update.message.chat.id;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
throw new Error('Telegram pairing code expired');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function sendTelegram(token, chatId, text, fetchImpl = fetch) {
|
|
55
|
+
let part = '';
|
|
56
|
+
const chunks = [];
|
|
57
|
+
for (const character of text) {
|
|
58
|
+
if (part.length + character.length > 4096) {
|
|
59
|
+
chunks.push(part);
|
|
60
|
+
part = '';
|
|
61
|
+
}
|
|
62
|
+
part += character;
|
|
63
|
+
}
|
|
64
|
+
if (part) chunks.push(part);
|
|
65
|
+
for (const chunk of chunks) {
|
|
66
|
+
let response;
|
|
67
|
+
try {
|
|
68
|
+
response = await fetchImpl(`https://api.telegram.org/bot${token}/sendMessage`, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: { 'Content-Type': 'application/json' },
|
|
71
|
+
body: JSON.stringify({ chat_id: chatId, text: chunk }),
|
|
72
|
+
signal: AbortSignal.timeout(10_000),
|
|
73
|
+
});
|
|
74
|
+
} catch {
|
|
75
|
+
throw new Error('Telegram delivery request failed');
|
|
76
|
+
}
|
|
77
|
+
if (!response.ok) throw new Error(`Telegram delivery failed (HTTP ${response.status})`);
|
|
78
|
+
let data;
|
|
79
|
+
try { data = await response.json(); } catch { throw new Error('Invalid Telegram delivery response'); }
|
|
80
|
+
if (!data.ok) throw new Error('Telegram delivery failed');
|
|
81
|
+
}
|
|
82
|
+
}
|