mirror of
https://github.com/harivansh-afk/pi-telegram-webhook.git
synced 2026-04-15 04:03:30 +00:00
initial: webhook telegram adapter for pi with streaming replies
- webhook server with secret validation, rate limiting, body guards - streaming replies via sendMessage + editMessageText throttled loop - RPC session management for persistent conversations - 15/15 tests passing
This commit is contained in:
parent
809e9b1df5
commit
ce9abc2a8e
18 changed files with 6991 additions and 1 deletions
107
tests/streaming-reply.test.ts
Normal file
107
tests/streaming-reply.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* Tests for streaming reply logic.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { StreamingReply } from "../src/streaming-reply.js";
|
||||
import type { TelegramAPI } from "../src/telegram-api.js";
|
||||
|
||||
describe("streaming-reply", () => {
|
||||
it("should throttle edits", async () => {
|
||||
const sent: Array<{ type: string; text: string }> = [];
|
||||
const mockApi = {
|
||||
sendMessage: vi.fn(async (_chatId: string, text: string) => {
|
||||
sent.push({ type: "send", text });
|
||||
return { message_id: 1 };
|
||||
}),
|
||||
editMessageText: vi.fn(async (_chatId: string, _msgId: number, text: string) => {
|
||||
sent.push({ type: "edit", text });
|
||||
}),
|
||||
} as unknown as TelegramAPI;
|
||||
|
||||
const streaming = new StreamingReply(mockApi, "123", 1, {
|
||||
throttleMs: 100,
|
||||
minInitialChars: 10,
|
||||
});
|
||||
|
||||
streaming.update("Hello");
|
||||
streaming.update("Hello world");
|
||||
streaming.update("Hello world!!!");
|
||||
|
||||
// Wait for throttle + initial char check
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
|
||||
expect(sent.length).toBeGreaterThan(0);
|
||||
expect(sent[0]?.type).toBe("send");
|
||||
expect(sent[0]?.text).toContain("Hello");
|
||||
|
||||
await streaming.stop();
|
||||
});
|
||||
|
||||
it("should track generation to prevent clobbering", () => {
|
||||
const mockApi = {
|
||||
sendMessage: vi.fn(async () => ({ message_id: 1 })),
|
||||
editMessageText: vi.fn(),
|
||||
} as unknown as TelegramAPI;
|
||||
|
||||
const streaming1 = new StreamingReply(mockApi, "123", 1, { throttleMs: 1000 });
|
||||
const streaming2 = new StreamingReply(mockApi, "123", 2, { throttleMs: 1000 });
|
||||
|
||||
expect(streaming1.getGeneration()).toBe(1);
|
||||
expect(streaming2.getGeneration()).toBe(2);
|
||||
expect(streaming1.getGeneration()).not.toBe(streaming2.getGeneration());
|
||||
});
|
||||
|
||||
it("should truncate text exceeding 4096 chars", async () => {
|
||||
const sent: string[] = [];
|
||||
const mockApi = {
|
||||
sendMessage: vi.fn(async (_chatId: string, text: string) => {
|
||||
sent.push(text);
|
||||
return { message_id: 1 };
|
||||
}),
|
||||
editMessageText: vi.fn(),
|
||||
} as unknown as TelegramAPI;
|
||||
|
||||
const streaming = new StreamingReply(mockApi, "123", 1, {
|
||||
throttleMs: 50,
|
||||
minInitialChars: 0,
|
||||
});
|
||||
|
||||
streaming.update("x".repeat(5000));
|
||||
await streaming.flush();
|
||||
|
||||
expect(sent.length).toBe(1);
|
||||
expect(sent[0]!.length).toBe(4096);
|
||||
|
||||
await streaming.stop();
|
||||
});
|
||||
|
||||
it("should debounce first send based on minInitialChars", async () => {
|
||||
const sent: string[] = [];
|
||||
const mockApi = {
|
||||
sendMessage: vi.fn(async (_chatId: string, text: string) => {
|
||||
sent.push(text);
|
||||
return { message_id: 1 };
|
||||
}),
|
||||
editMessageText: vi.fn(),
|
||||
} as unknown as TelegramAPI;
|
||||
|
||||
const streaming = new StreamingReply(mockApi, "123", 1, {
|
||||
throttleMs: 50,
|
||||
minInitialChars: 20,
|
||||
});
|
||||
|
||||
streaming.update("Short");
|
||||
await streaming.flush();
|
||||
|
||||
// Should not send yet
|
||||
expect(sent.length).toBe(0);
|
||||
|
||||
streaming.update("This is now long enough to send");
|
||||
await streaming.flush();
|
||||
|
||||
expect(sent.length).toBe(1);
|
||||
|
||||
await streaming.stop();
|
||||
});
|
||||
});
|
||||
123
tests/telegram-api.test.ts
Normal file
123
tests/telegram-api.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* Tests for Telegram API wrapper.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { TelegramAPI } from "../src/telegram-api.js";
|
||||
|
||||
describe("telegram-api", () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("should call getMe", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, result: { id: 123, first_name: "Bot" } }),
|
||||
});
|
||||
|
||||
const api = new TelegramAPI("test-token");
|
||||
const result = await api.getMe();
|
||||
|
||||
expect(result).toEqual({ id: 123, first_name: "Bot" });
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://api.telegram.org/bottest-token/getMe",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
});
|
||||
|
||||
it("should call sendMessage", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, result: { message_id: 456 } }),
|
||||
});
|
||||
|
||||
const api = new TelegramAPI("test-token");
|
||||
const result = await api.sendMessage("123", "Hello");
|
||||
|
||||
expect(result).toEqual({ message_id: 456 });
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://api.telegram.org/bottest-token/sendMessage",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ chat_id: "123", text: "Hello" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should call editMessageText", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, result: true }),
|
||||
});
|
||||
|
||||
const api = new TelegramAPI("test-token");
|
||||
await api.editMessageText("123", 456, "Updated");
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://api.telegram.org/bottest-token/editMessageText",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ chat_id: "123", message_id: 456, text: "Updated" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should call deleteMessage", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, result: true }),
|
||||
});
|
||||
|
||||
const api = new TelegramAPI("test-token");
|
||||
await api.deleteMessage("123", 456);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://api.telegram.org/bottest-token/deleteMessage",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ chat_id: "123", message_id: 456 }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should call setWebhook", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, result: true }),
|
||||
});
|
||||
|
||||
const api = new TelegramAPI("test-token");
|
||||
await api.setWebhook("https://example.com/webhook", "secret");
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://api.telegram.org/bottest-token/setWebhook",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
url: "https://example.com/webhook",
|
||||
secret_token: "secret",
|
||||
allowed_updates: ["message"],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle API errors", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: async () => "Unauthorized",
|
||||
});
|
||||
|
||||
const api = new TelegramAPI("bad-token");
|
||||
|
||||
await expect(api.getMe()).rejects.toThrow("Telegram API getMe failed (401): Unauthorized");
|
||||
});
|
||||
});
|
||||
195
tests/webhook-server.test.ts
Normal file
195
tests/webhook-server.test.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
/**
|
||||
* Tests for webhook server security features.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { startWebhookServer } from "../src/webhook-server.js";
|
||||
import type { TelegramUpdate } from "../src/types.js";
|
||||
|
||||
describe("webhook-server", () => {
|
||||
it("should reject requests without secret", async () => {
|
||||
const received: TelegramUpdate[] = [];
|
||||
const { server, stop } = startWebhookServer({
|
||||
port: 0, // Random port
|
||||
host: "127.0.0.1",
|
||||
path: "/webhook",
|
||||
secret: "test-secret",
|
||||
trustedProxies: [],
|
||||
onUpdate: (update) => received.push(update),
|
||||
});
|
||||
|
||||
// Wait for server to be listening
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
const address = server.address();
|
||||
const port = address && typeof address === "object" ? address.port : 0;
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/webhook`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ update_id: 1, message: { message_id: 1 } }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(received.length).toBe(0);
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should accept requests with valid secret", async () => {
|
||||
const received: TelegramUpdate[] = [];
|
||||
const { server, stop } = startWebhookServer({
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
path: "/webhook",
|
||||
secret: "test-secret",
|
||||
trustedProxies: [],
|
||||
onUpdate: (update) => received.push(update),
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
const address = server.address();
|
||||
const port = address && typeof address === "object" ? address.port : 0;
|
||||
|
||||
try {
|
||||
const update: TelegramUpdate = {
|
||||
update_id: 1,
|
||||
message: {
|
||||
message_id: 1,
|
||||
chat: { id: 123, type: "private" },
|
||||
date: Date.now(),
|
||||
text: "hello",
|
||||
},
|
||||
};
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${port}/webhook`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Telegram-Bot-Api-Secret-Token": "test-secret",
|
||||
},
|
||||
body: JSON.stringify(update),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(received.length).toBe(1);
|
||||
expect(received[0]?.update_id).toBe(1);
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject payloads exceeding body limit", async () => {
|
||||
const received: TelegramUpdate[] = [];
|
||||
const { server, stop } = startWebhookServer({
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
path: "/webhook",
|
||||
secret: "test-secret",
|
||||
trustedProxies: [],
|
||||
onUpdate: (update) => received.push(update),
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
const address = server.address();
|
||||
const port = address && typeof address === "object" ? address.port : 0;
|
||||
|
||||
try {
|
||||
const largePayload = JSON.stringify({
|
||||
update_id: 1,
|
||||
message: { text: "x".repeat(2_000_000) }, // > 1MB
|
||||
});
|
||||
|
||||
// When payload exceeds limit, server may respond with 413 or destroy connection
|
||||
let rejected = false;
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/webhook`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Telegram-Bot-Api-Secret-Token": "test-secret",
|
||||
},
|
||||
body: largePayload,
|
||||
});
|
||||
rejected = res.status === 413;
|
||||
} catch (err: any) {
|
||||
// Connection destroyed (ECONNRESET) is also acceptable
|
||||
rejected = err.code === "ECONNRESET" || err.message?.includes("ECONNRESET") || err.cause?.code === "ECONNRESET";
|
||||
}
|
||||
|
||||
expect(rejected).toBe(true);
|
||||
expect(received.length).toBe(0);
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should enforce rate limits per IP", async () => {
|
||||
const received: TelegramUpdate[] = [];
|
||||
const { server, stop } = startWebhookServer({
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
path: "/webhook",
|
||||
secret: "test-secret",
|
||||
trustedProxies: [],
|
||||
onUpdate: (update) => received.push(update),
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
const address = server.address();
|
||||
const port = address && typeof address === "object" ? address.port : 0;
|
||||
|
||||
try {
|
||||
// Make 65 requests (rate limit is 60/min)
|
||||
const requests = [];
|
||||
for (let i = 0; i < 65; i++) {
|
||||
requests.push(
|
||||
fetch(`http://127.0.0.1:${port}/webhook`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Telegram-Bot-Api-Secret-Token": "test-secret",
|
||||
},
|
||||
body: JSON.stringify({ update_id: i }),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(requests);
|
||||
const statuses = results.map((r) => r.status);
|
||||
|
||||
// First 60 should succeed, rest should be rate limited
|
||||
const ok = statuses.filter((s) => s === 200).length;
|
||||
const rateLimited = statuses.filter((s) => s === 429).length;
|
||||
|
||||
expect(ok).toBe(60);
|
||||
expect(rateLimited).toBe(5);
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should respond to /healthz", async () => {
|
||||
const { server, stop } = startWebhookServer({
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
path: "/webhook",
|
||||
secret: "test-secret",
|
||||
trustedProxies: [],
|
||||
onUpdate: () => {},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
const address = server.address();
|
||||
const port = address && typeof address === "object" ? address.port : 0;
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/healthz`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("ok");
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue