pi-telegram-notifier 0.1.2 → 0.1.4

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
@@ -12,7 +12,7 @@ export PI_TELEGRAM_BOT_TOKEN='your-bot-token'
12
12
  pi
13
13
  ```
14
14
 
15
- If Pi is already running, run `/reload` after installing the package (restart Pi if you just set the token). If you previously installed a local checkout, run `pi list`, then `pi remove /absolute/path/to/pi-telegram-notifier` to avoid loading both copies. To get future releases, run `pi update npm:pi-telegram-notifier`. 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.
15
+ If Pi is already running, run `/reload` after installing the package (restart Pi if you just set the token). If you previously installed a local checkout, run `pi list`, then `pi remove /absolute/path/to/pi-telegram-notifier` to avoid loading both copies. To get future releases, run `pi update npm:pi-telegram-notifier`. In Pi run `/telegram-pair`. Use `/telegram-status` to see whether the bot token is configured and a private chat is paired; it reports local state and does not verify live Telegram connectivity. 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.
16
16
 
17
17
  **Privacy:** final agent responses and the device hostname are sent to Telegram. Responses 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.
18
18
 
@@ -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,13 +2,28 @@ 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 { loadChatId, 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
 
10
10
  export default function (pi: ExtensionAPI) {
11
11
  registerNotifications(pi, agentDir(), process.env.PI_TELEGRAM_BOT_TOKEN);
12
+ pi.registerCommand('telegram-status', {
13
+ description: 'Show the Telegram bot configuration and pairing status',
14
+ handler: async (_args, ctx) => {
15
+ const tokenConfigured = !!process.env.PI_TELEGRAM_BOT_TOKEN;
16
+ const paired = loadChatId(agentDir()) !== undefined;
17
+ ctx.ui.notify([
18
+ 'Telegram status',
19
+ `Bot token: ${tokenConfigured ? 'configured' : 'not configured'}`,
20
+ `Private chat: ${paired ? 'paired' : 'not paired'}`,
21
+ 'Live connectivity: not checked',
22
+ !tokenConfigured ? 'Set PI_TELEGRAM_BOT_TOKEN and restart Pi.' : '',
23
+ !paired ? 'Run /telegram-pair to pair a private chat.' : '',
24
+ ].filter(Boolean).join('\n'), 'info');
25
+ },
26
+ });
12
27
  pi.registerCommand('telegram-pair', {
13
28
  description: 'Pair a private Telegram chat with this Pi installation',
14
29
  handler: async (_args, ctx) => {
package/package.json CHANGED
@@ -1,14 +1,16 @@
1
1
  {
2
2
  "name": "pi-telegram-notifier",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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,7 +30,7 @@ export function registerNotifications(pi, agentDir, token, fetchImpl = fetch) {
27
30
  startLeafId = undefined;
28
31
  const chatId = loadChatId(agentDir);
29
32
  if (!chatId || !response) return;
30
- let quota;
33
+ let quota: string;
31
34
  try {
32
35
  const model = ctx.modelRegistry?.find(response.provider, response.modelId);
33
36
  quota = await getQuota(model, ctx.modelRegistry, fetchImpl);
@@ -1,6 +1,10 @@
1
+ import type { ModelRegistry } from '@earendil-works/pi-coding-agent';
2
+
1
3
  const UNAVAILABLE = 'Quota unavailable';
2
4
 
3
- const providers = {
5
+ type Model = NonNullable<ReturnType<ModelRegistry['find']>>;
6
+
7
+ const providers: Record<string, { origin: string; url: string }> = {
4
8
  openrouter: { origin: 'https://openrouter.ai', url: 'https://openrouter.ai/api/v1/credits' },
5
9
  'openai-codex': { origin: 'https://chatgpt.com', url: 'https://chatgpt.com/backend-api/wham/usage' },
6
10
  deepseek: { origin: 'https://api.deepseek.com', url: 'https://api.deepseek.com/user/balance' },
@@ -12,19 +16,19 @@ const providers = {
12
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
27
  if (new URL(model.baseUrl).origin !== provider.origin) 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.origin)) return UNAVAILABLE;
27
- const headers = { Authorization: ['zai', 'zai-coding-cn'].includes(model.provider) ? auth.apiKey : `Bearer ${auth.apiKey}` };
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';
@@ -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
  }
@@ -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);