pi-telegram-notifier 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,6 +41,8 @@ Telegram has a message-length limit; long responses are delivered in consecutive
41
41
  ## Develop and release
42
42
 
43
43
  ```sh
44
+ npm ci
45
+ npm run typecheck
44
46
  npm test
45
47
  npm pack --dry-run --json
46
48
  pi --no-extensions -e . --help
@@ -2,8 +2,8 @@ import { randomBytes } from 'node:crypto';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
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';
5
+ import { pairChat, saveChatId } from '#src/telegram';
6
+ import { registerNotifications } from '#src/notification';
7
7
 
8
8
  const agentDir = () => process.env.PI_CODING_AGENT_DIR || join(homedir(), '.pi', 'agent');
9
9
 
package/package.json CHANGED
@@ -1,14 +1,16 @@
1
1
  {
2
2
  "name": "pi-telegram-notifier",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Telegram notifications for final Pi agent responses and provider balances",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "engines": { "node": ">=22.19.0" },
8
8
  "keywords": ["pi-package", "pi", "telegram", "notifications"],
9
9
  "files": ["extensions", "src", "README.md", "LICENSE", "docs/providers.md"],
10
- "scripts": { "test": "node --test" },
10
+ "imports": { "#src/*": "./src/*.ts" },
11
+ "scripts": { "test": "node --test", "typecheck": "tsc --noEmit" },
11
12
  "pi": { "extensions": ["./extensions/index.ts"] },
12
13
  "peerDependencies": { "@earendil-works/pi-coding-agent": "*" },
14
+ "devDependencies": { "@types/node": "22.20.4", "typescript": "5.9.3" },
13
15
  "repository": { "type": "git", "url": "git+https://github.com/Gybra/pi-telegram-notifier.git" }
14
16
  }
@@ -1,8 +1,11 @@
1
1
  import { hostname } from 'node:os';
2
- import { loadChatId, sendTelegram } from './telegram.js';
3
- import { getQuota } from './quota.js';
2
+ import type { ExtensionAPI, SessionEntry } from '@earendil-works/pi-coding-agent';
3
+ import { loadChatId, sendTelegram } from '#src/telegram';
4
+ import { getQuota } from '#src/quota';
4
5
 
5
- export function finalResponse(branch, startLeafId) {
6
+ type FinalResponse = { text: string; provider: string; modelId: string };
7
+
8
+ export function finalResponse(branch: SessionEntry[], startLeafId: string | null): FinalResponse | undefined {
6
9
  const index = startLeafId === null ? -1 : branch.findIndex((entry) => entry.id === startLeafId);
7
10
  if (index < 0 && startLeafId !== null) return undefined;
8
11
  const latest = branch.slice(index + 1).filter((entry) => entry.type === 'message').at(-1)?.message;
@@ -12,12 +15,12 @@ export function finalResponse(branch, startLeafId) {
12
15
  return { text, provider: latest.provider, modelId: latest.model };
13
16
  }
14
17
 
15
- export function formatNotification(timestamp, response, quota) {
18
+ export function formatNotification(timestamp: string, response: FinalResponse, quota: string): string {
16
19
  return `${timestamp}\nDevice: ${hostname()}\nProvider: ${response.provider}\n${quota}\n\n${response.text}`;
17
20
  }
18
21
 
19
- export function registerNotifications(pi, agentDir, token, fetchImpl = fetch) {
20
- let startLeafId;
22
+ export function registerNotifications(pi: ExtensionAPI, agentDir: string, token: string | undefined, fetchImpl: typeof fetch = fetch): void {
23
+ let startLeafId: string | null | undefined;
21
24
  pi.on('agent_start', (_event, ctx) => {
22
25
  startLeafId = ctx.sessionManager.getLeafId();
23
26
  });
@@ -27,9 +30,14 @@ export function registerNotifications(pi, agentDir, token, fetchImpl = fetch) {
27
30
  startLeafId = undefined;
28
31
  const chatId = loadChatId(agentDir);
29
32
  if (!chatId || !response) return;
33
+ let quota: string;
30
34
  try {
31
35
  const model = ctx.modelRegistry?.find(response.provider, response.modelId);
32
- const quota = await getQuota(model, ctx.modelRegistry, fetchImpl);
36
+ quota = await getQuota(model, ctx.modelRegistry, fetchImpl);
37
+ } catch {
38
+ quota = 'Quota unavailable';
39
+ }
40
+ try {
33
41
  await sendTelegram(token, chatId,
34
42
  formatNotification(new Date().toISOString(), response, quota), fetchImpl);
35
43
  } catch {
@@ -1,30 +1,34 @@
1
+ import type { ModelRegistry } from '@earendil-works/pi-coding-agent';
2
+
1
3
  const UNAVAILABLE = 'Quota unavailable';
2
4
 
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'],
5
+ type Model = NonNullable<ReturnType<ModelRegistry['find']>>;
6
+
7
+ const providers: Record<string, { origin: string; url: string }> = {
8
+ openrouter: { origin: 'https://openrouter.ai', url: 'https://openrouter.ai/api/v1/credits' },
9
+ 'openai-codex': { origin: 'https://chatgpt.com', url: 'https://chatgpt.com/backend-api/wham/usage' },
10
+ deepseek: { origin: 'https://api.deepseek.com', url: 'https://api.deepseek.com/user/balance' },
11
+ moonshotai: { origin: 'https://api.moonshot.ai', url: 'https://api.moonshot.ai/v1/users/me/balance' },
12
+ 'moonshotai-cn': { origin: 'https://api.moonshot.cn', url: 'https://api.moonshot.cn/v1/users/me/balance' },
13
+ zai: { origin: 'https://api.z.ai', url: 'https://api.z.ai/api/monitor/usage/quota/limit' },
14
+ 'zai-coding-cn': { origin: 'https://open.bigmodel.cn', url: 'https://open.bigmodel.cn/api/monitor/usage/quota/limit' },
15
+ anthropic: { origin: 'https://api.anthropic.com', url: 'https://api.anthropic.com/api/oauth/usage' },
16
+ xai: { origin: 'https://api.x.ai', url: 'https://cli-chat-proxy.grok.com/v1/billing?format=credits' },
13
17
  };
14
18
 
15
- function remainingPercent(used) {
19
+ function remainingPercent(used: unknown): number | undefined {
16
20
  return typeof used === 'number' && Number.isFinite(used) && used >= 0 && used <= 100 ? 100 - used : undefined;
17
21
  }
18
22
 
19
- export async function getQuota(model, modelRegistry, fetchImpl = fetch) {
23
+ export async function getQuota(model: Model | undefined, modelRegistry: Pick<ModelRegistry, 'getApiKeyAndHeaders' | 'isUsingOAuth'> | undefined, fetchImpl: typeof fetch = fetch): Promise<string> {
20
24
  const provider = model && providers[model.provider];
21
25
  if (!provider) return UNAVAILABLE;
22
26
  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}` };
27
+ if (new URL(model.baseUrl).origin !== provider.origin) return UNAVAILABLE;
28
+ if (['anthropic', 'xai'].includes(model.provider) && !modelRegistry?.isUsingOAuth(model)) return UNAVAILABLE;
29
+ const auth = await modelRegistry?.getApiKeyAndHeaders(model);
30
+ if (!auth?.ok || !auth.apiKey || (auth.baseUrl && new URL(auth.baseUrl).origin !== provider.origin)) return UNAVAILABLE;
31
+ const headers: Record<string, string> = { Authorization: ['zai', 'zai-coding-cn'].includes(model.provider) ? auth.apiKey : `Bearer ${auth.apiKey}` };
28
32
  if (model.provider === 'anthropic') {
29
33
  headers['anthropic-beta'] = 'oauth-2025-04-20';
30
34
  headers['anthropic-version'] = '2023-06-01';
@@ -35,7 +39,7 @@ export async function getQuota(model, modelRegistry, fetchImpl = fetch) {
35
39
  if (typeof accountId !== 'string' || !accountId) return UNAVAILABLE;
36
40
  headers['ChatGPT-Account-Id'] = accountId;
37
41
  }
38
- const response = await fetchImpl(provider[1], {
42
+ const response = await fetchImpl(provider.url, {
39
43
  headers,
40
44
  signal: AbortSignal.timeout(10_000),
41
45
  });
@@ -43,7 +47,7 @@ export async function getQuota(model, modelRegistry, fetchImpl = fetch) {
43
47
  const data = await response.json();
44
48
  if (['zai', 'zai-coding-cn'].includes(model.provider)) {
45
49
  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;
50
+ const used = data.data?.limits?.find((limit: { type: string; percentage: number }) => limit.type === 'TOKENS_LIMIT')?.percentage;
47
51
  const remaining = remainingPercent(used);
48
52
  return remaining === undefined ? UNAVAILABLE : `Remaining quota: 5h ${remaining}%`;
49
53
  }
@@ -62,7 +66,7 @@ export async function getQuota(model, modelRegistry, fetchImpl = fetch) {
62
66
  if (remaining === undefined && config.creditUsagePercent === undefined) {
63
67
  const { currentPeriod, productUsage } = config;
64
68
  if (Date.parse(currentPeriod.start) < Date.parse(currentPeriod.end) &&
65
- (productUsage === undefined || (Array.isArray(productUsage) && productUsage.every((item) => item?.usagePercent === 0)))) remaining = 100;
69
+ Array.isArray(productUsage) && productUsage.length > 0 && productUsage.every((item) => item?.usagePercent === 0)) remaining = 100;
66
70
  }
67
71
  return remaining === undefined ? UNAVAILABLE : `Remaining quota: 7d ${remaining}%`;
68
72
  }
@@ -85,7 +89,7 @@ export async function getQuota(model, modelRegistry, fetchImpl = fetch) {
85
89
  }
86
90
  if (model.provider === 'deepseek') {
87
91
  if (!Array.isArray(data.balance_infos) || !data.balance_infos.length) return UNAVAILABLE;
88
- const balances = data.balance_infos.map(({ currency, total_balance }) => {
92
+ const balances = data.balance_infos.map(({ currency, total_balance }: { currency: string; total_balance: string }) => {
89
93
  if (!['CNY', 'USD'].includes(currency) || typeof total_balance !== 'string' ||
90
94
  !/^\d+(?:\.\d+)?$/.test(total_balance)) return undefined;
91
95
  return `${total_balance} ${currency}`;
@@ -2,18 +2,18 @@ import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { randomBytes } from 'node:crypto';
4
4
 
5
- const stateFile = (agentDir) => join(agentDir, 'pi-telegram-notifier.json');
5
+ const stateFile = (agentDir: string) => join(agentDir, 'pi-telegram-notifier.json');
6
6
 
7
- export function loadChatId(agentDir) {
7
+ export function loadChatId(agentDir: string): number | undefined {
8
8
  try {
9
- const id = JSON.parse(readFileSync(stateFile(agentDir), 'utf8')).chatId;
10
- return Number.isSafeInteger(id) && id > 0 ? id : undefined;
9
+ const id: unknown = JSON.parse(readFileSync(stateFile(agentDir), 'utf8')).chatId;
10
+ return typeof id === 'number' && Number.isSafeInteger(id) && id > 0 ? id : undefined;
11
11
  } catch {
12
12
  return undefined;
13
13
  }
14
14
  }
15
15
 
16
- export function saveChatId(agentDir, chatId) {
16
+ export function saveChatId(agentDir: string, chatId: number): void {
17
17
  if (!Number.isSafeInteger(chatId) || chatId <= 0) throw new Error('Invalid private chat ID');
18
18
  mkdirSync(agentDir, { recursive: true, mode: 0o700 });
19
19
  const temporary = `${stateFile(agentDir)}.${randomBytes(8).toString('hex')}`;
@@ -21,7 +21,7 @@ export function saveChatId(agentDir, chatId) {
21
21
  renameSync(temporary, stateFile(agentDir));
22
22
  }
23
23
 
24
- export async function pairChat(token, code, fetchImpl = fetch, now = Date.now) {
24
+ export async function pairChat(token: string, code: string, fetchImpl: typeof fetch = fetch, now: () => number = Date.now): Promise<number> {
25
25
  const expires = now() + 300_000;
26
26
  let offset = 0;
27
27
  while (now() < expires) {
@@ -51,9 +51,9 @@ export async function pairChat(token, code, fetchImpl = fetch, now = Date.now) {
51
51
  throw new Error('Telegram pairing code expired');
52
52
  }
53
53
 
54
- export async function sendTelegram(token, chatId, text, fetchImpl = fetch) {
54
+ export async function sendTelegram(token: string, chatId: number, text: string, fetchImpl: typeof fetch = fetch): Promise<void> {
55
55
  let part = '';
56
- const chunks = [];
56
+ const chunks: string[] = [];
57
57
  for (const character of text) {
58
58
  if (part.length + character.length > 4096) {
59
59
  chunks.push(part);