mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-23 00:21:28 +00:00
feat: add Telegram push support with multi-target routes
Add a platform-aware route target system so a single route can forward to several destinations at once (e.g. a Discord channel and a Telegram group). Route.target becomes Route.targets[] with per-entry platform, channelId/threadId for Discord and chatId/topicId for Telegram; the legacy single-target format is normalized on load and accepted by the admin API. Implement the Telegram driver with HTML rendering and Bot API sendMessage (chat_id + message_thread_id for topics, retry on 429/5xx), plus /gh commands served over POST /telegram/webhook: login, logout, comment, merge and close. The comment/merge/close commands resolve the issue or PR from the replied-to notification message. OAuth binding now stores a D1 telegram_links mapping and replies with a confirmation. Sync the Telegram webhook from the scheduled trigger via setWebhook.
This commit is contained in:
parent
dcbe93be91
commit
bd7a8f2632
39 changed files with 1165 additions and 162 deletions
|
|
@ -52,7 +52,7 @@ const sampleRoutes: Route[] = [
|
|||
name: "Backend PRs",
|
||||
enabled: true,
|
||||
filters: [{ type: "event", match: "pull_request" }],
|
||||
target: { channelId: "111" },
|
||||
targets: [{ channelId: "111" }],
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -133,6 +133,6 @@ describe("config routes persistence", () => {
|
|||
const second = await loadConfig(env);
|
||||
expect(second.routes).toHaveLength(1);
|
||||
expect(second.routes[0]!.id).toBe("backend-prs");
|
||||
expect(second.routes[0]!.target.channelId).toBe("111");
|
||||
expect(second.routes[0]!.targets[0]!.channelId).toBe("111");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ describe("dispatchEvent fallback routing", () => {
|
|||
name: "Regular Push",
|
||||
enabled: true,
|
||||
filters: [{ type: "event", match: "push" }],
|
||||
target: { channelId: "111" },
|
||||
targets: [{ channelId: "111" }],
|
||||
},
|
||||
{
|
||||
id: "catch-all",
|
||||
|
|
@ -137,7 +137,7 @@ describe("dispatchEvent fallback routing", () => {
|
|||
enabled: true,
|
||||
filters: [],
|
||||
fallback: true,
|
||||
target: { channelId: "222" },
|
||||
targets: [{ channelId: "222" }],
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ const route: Route = {
|
|||
name: "Test",
|
||||
enabled: true,
|
||||
filters: [],
|
||||
target: { channelId: "111" },
|
||||
targets: [{ channelId: "111" }],
|
||||
};
|
||||
|
||||
function event(ev: string, payload: Record<string, unknown>): WebhookEvent {
|
||||
|
|
|
|||
201
src/__tests__/telegram-commands.test.ts
Normal file
201
src/__tests__/telegram-commands.test.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { describe, it, expect, afterEach } from "bun:test";
|
||||
import { handleTelegramUpdate } from "../drivers/telegram/commands";
|
||||
import { handleTelegramWebhookRequest } from "../drivers/telegram/updates";
|
||||
import type { Env } from "../types";
|
||||
|
||||
function mockFetch(handler: (url: string, init?: RequestInit) => Response): void {
|
||||
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> =>
|
||||
Promise.resolve(handler(String(input), init));
|
||||
}
|
||||
|
||||
const restoredFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = restoredFetch;
|
||||
});
|
||||
|
||||
function createMockKV(): KVNamespace {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
get: async (key: string, type?: string) => {
|
||||
const v = store.get(key);
|
||||
if (v === undefined) return null;
|
||||
return type === "json" ? JSON.parse(v) : v;
|
||||
},
|
||||
put: async (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
delete: async (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
list: async () => ({ keys: [] }),
|
||||
} as unknown as KVNamespace;
|
||||
}
|
||||
|
||||
function createMockDB(): D1Database {
|
||||
const links = new Map<string, string>();
|
||||
return {
|
||||
prepare: (sql: string) => ({
|
||||
bind: (...args: unknown[]) => ({
|
||||
run: async (): Promise<{ success: boolean }> => {
|
||||
const m = sql.match(/INSERT OR REPLACE INTO telegram_links \(telegram_user_id, github_user_id\) VALUES \(\?, \?\)/);
|
||||
if (m) links.set(String(args[0]), String(args[1]));
|
||||
const del = sql.match(/DELETE FROM telegram_links WHERE telegram_user_id = \?/);
|
||||
if (del) links.delete(String(args[0]));
|
||||
return { success: true };
|
||||
},
|
||||
all: async (): Promise<{ results: Array<Record<string, unknown>> }> => {
|
||||
const sel = sql.match(/SELECT github_user_id FROM telegram_links WHERE telegram_user_id = \?/);
|
||||
if (sel) {
|
||||
const val = links.get(String(args[0]));
|
||||
return { results: val ? [{ github_user_id: val }] : [] };
|
||||
}
|
||||
return { results: [] };
|
||||
},
|
||||
first: async () => null,
|
||||
}),
|
||||
}),
|
||||
} as unknown as D1Database;
|
||||
}
|
||||
|
||||
function createEnv(): Env {
|
||||
return {
|
||||
GITHUB_WEBHOOK_SECRET: "secret",
|
||||
GITHUB_CLIENT_ID: "client-id",
|
||||
TELEGRAM_TOKEN: "tg-token",
|
||||
TELEGRAM_WEBHOOK_SECRET: "wh-secret",
|
||||
KV: createMockKV(),
|
||||
DB: createMockDB(),
|
||||
} as Env;
|
||||
}
|
||||
|
||||
function reply(chatId: string, topicId?: number): Record<string, unknown> {
|
||||
return {
|
||||
message_id: 1,
|
||||
from: { id: 111, first_name: "Rhen" },
|
||||
chat: { id: chatId, type: "supergroup" },
|
||||
message_thread_id: topicId,
|
||||
};
|
||||
}
|
||||
|
||||
describe("telegram-commands /gh login", () => {
|
||||
it("stores state with telegramUserId and replies with OAuth URL", async () => {
|
||||
let sentBody: Record<string, unknown> | undefined;
|
||||
mockFetch((_url, init) => {
|
||||
sentBody = JSON.parse(String(init!.body)) as Record<string, unknown>;
|
||||
return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { status: 200 });
|
||||
});
|
||||
|
||||
const env = createEnv();
|
||||
await handleTelegramUpdate(env, {
|
||||
message: { ...reply("-100123"), text: "/gh login" },
|
||||
});
|
||||
|
||||
expect(sentBody?.chat_id).toBe("-100123");
|
||||
expect(String(sentBody?.text)).toContain("github.com/login/oauth/authorize");
|
||||
});
|
||||
});
|
||||
|
||||
describe("telegram-commands /gh logout", () => {
|
||||
it("replies bound/unbound message", async () => {
|
||||
let sentText = "";
|
||||
mockFetch((_url, init) => {
|
||||
sentText = String((JSON.parse(String(init!.body)) as Record<string, unknown>).text);
|
||||
return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { status: 200 });
|
||||
});
|
||||
|
||||
const env = createEnv();
|
||||
await handleTelegramUpdate(env, {
|
||||
message: { ...reply("-100123"), text: "/gh logout" },
|
||||
});
|
||||
|
||||
expect(sentText).toContain("已解绑");
|
||||
});
|
||||
});
|
||||
|
||||
describe("telegram-commands /gh comment", () => {
|
||||
it("replies when no reply_to_message link present", async () => {
|
||||
let sentText = "";
|
||||
mockFetch((_url, init) => {
|
||||
sentText = String((JSON.parse(String(init!.body)) as Record<string, unknown>).text);
|
||||
return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { status: 200 });
|
||||
});
|
||||
|
||||
const env = createEnv();
|
||||
await handleTelegramUpdate(env, {
|
||||
message: { ...reply("-100123"), text: "/gh comment hello" },
|
||||
});
|
||||
|
||||
expect(sentText).toContain("还没有绑定");
|
||||
});
|
||||
|
||||
it("parses the replied-to GitHub link as target", async () => {
|
||||
const env = createEnv();
|
||||
const { saveTelegramLink } = await import("../github/store");
|
||||
await saveTelegramLink(env.DB, "111", "111980217");
|
||||
await env.KV.put(
|
||||
"token:111980217",
|
||||
JSON.stringify({
|
||||
userId: "111980217",
|
||||
accessToken: "ghu_test",
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
}),
|
||||
);
|
||||
|
||||
const calls: Array<{ url: string; body: string }> = [];
|
||||
mockFetch((url, init) => {
|
||||
calls.push({ url, body: String(init!.body) });
|
||||
if (String(url).endsWith("/sendMessage")) {
|
||||
return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { status: 200 });
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({ html_url: "https://github.com/acme/widget/issues/7#issuecomment-9" }),
|
||||
{ status: 200 },
|
||||
);
|
||||
});
|
||||
|
||||
await handleTelegramUpdate(env, {
|
||||
message: {
|
||||
...reply("-100123"),
|
||||
text: "/gh comment hello",
|
||||
reply_to_message: {
|
||||
...reply("-100123"),
|
||||
text: "acme/widget#7: Add feature",
|
||||
entities: [{ type: "text_link", url: "https://github.com/acme/widget/issues/7" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const ghCall = calls.find((c) => c.url.includes("/repos/"));
|
||||
expect(ghCall).toBeDefined();
|
||||
expect(ghCall!.url).toContain("/repos/acme/widget/issues/7/comments");
|
||||
const ghBody = JSON.parse(ghCall!.body) as Record<string, unknown>;
|
||||
expect(ghBody.body).toBe("hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("telegram-updates webhook", () => {
|
||||
it("rejects requests without the secret token", async () => {
|
||||
const env = createEnv();
|
||||
const res = await handleTelegramWebhookRequest(
|
||||
new Request("https://example.com/telegram/webhook", { method: "POST", body: "{}" }),
|
||||
env,
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("accepts requests with the correct secret token", async () => {
|
||||
mockFetch(() => new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { status: 200 }));
|
||||
const env = createEnv();
|
||||
const res = await handleTelegramWebhookRequest(
|
||||
new Request("https://example.com/telegram/webhook", {
|
||||
method: "POST",
|
||||
headers: { "X-Telegram-Bot-Api-Secret-Token": "wh-secret" },
|
||||
body: JSON.stringify({ update_id: 1 }),
|
||||
}),
|
||||
env,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("ok");
|
||||
});
|
||||
});
|
||||
92
src/__tests__/telegram.test.ts
Normal file
92
src/__tests__/telegram.test.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { describe, it, expect, afterEach } from "bun:test";
|
||||
import { sendMessage } from "../drivers/telegram/rest";
|
||||
import { renderNeutralMessage } from "../drivers/telegram/render";
|
||||
import type { NeutralMessage } from "../types";
|
||||
|
||||
function mockFetch(handler: (url: string, init?: RequestInit) => Response): void {
|
||||
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> =>
|
||||
Promise.resolve(handler(String(input), init));
|
||||
}
|
||||
|
||||
const restoredFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = restoredFetch;
|
||||
});
|
||||
|
||||
describe("telegram renderNeutralMessage", () => {
|
||||
it("renders title, fields and footer as HTML", () => {
|
||||
const message: NeutralMessage = {
|
||||
title: "acme/widget: Add feature",
|
||||
url: "https://github.com/acme/widget",
|
||||
fields: [{ name: "Status", value: "success" }],
|
||||
footer: "acme/widget",
|
||||
};
|
||||
const out = renderNeutralMessage(message);
|
||||
expect(out).toContain('<b><a href="https://github.com/acme/widget">acme/widget: Add feature</a></b>');
|
||||
expect(out).toContain("<b>Status</b>: success");
|
||||
expect(out).toContain("<i>acme/widget</i>");
|
||||
});
|
||||
|
||||
it("escapes HTML special characters", () => {
|
||||
const out = renderNeutralMessage({
|
||||
title: "a <b> & \"c\"",
|
||||
fields: [{ name: "body", value: "<script>alert(1)</script>" }],
|
||||
});
|
||||
expect(out).not.toContain("<b>acme");
|
||||
expect(out).toContain("<script>alert(1)</script>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("telegram-rest sendMessage", () => {
|
||||
it("posts to the bot API with chat_id and parse_mode", async () => {
|
||||
let capturedUrl = "";
|
||||
let capturedInit: RequestInit | undefined;
|
||||
mockFetch((url, init) => {
|
||||
capturedUrl = url;
|
||||
capturedInit = init;
|
||||
return new Response(JSON.stringify({ ok: true, result: { message_id: 42 } }), {
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
|
||||
const result = await sendMessage("token-abc", "-100123", "hello");
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.messageId).toBe("42");
|
||||
expect(capturedUrl).toBe("https://api.telegram.org/bottoken-abc/sendMessage");
|
||||
const body = JSON.parse(String(capturedInit!.body)) as Record<string, unknown>;
|
||||
expect(body.chat_id).toBe("-100123");
|
||||
expect(body.parse_mode).toBe("HTML");
|
||||
expect(body.message_thread_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes message_thread_id when topicId is given", async () => {
|
||||
let capturedInit: RequestInit | undefined;
|
||||
mockFetch((_url, init) => {
|
||||
capturedInit = init;
|
||||
return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), {
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
|
||||
await sendMessage("t", "-100123", "hello", "999");
|
||||
const body = JSON.parse(String(capturedInit!.body)) as Record<string, unknown>;
|
||||
expect(body.message_thread_id).toBe(999);
|
||||
});
|
||||
|
||||
it("returns error on non-ok response", async () => {
|
||||
mockFetch(() =>
|
||||
new Response(JSON.stringify({ ok: false, description: "chat not found" }), { status: 400 }),
|
||||
);
|
||||
const result = await sendMessage("t", "-100123", "hello");
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("chat not found");
|
||||
});
|
||||
|
||||
it("returns error when token is missing", async () => {
|
||||
const result = await sendMessage("", "-100123", "hello");
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.errorCode).toBe("NO_TOKEN");
|
||||
});
|
||||
});
|
||||
|
|
@ -51,7 +51,7 @@ describe("matchRoute", () => {
|
|||
name: "Test",
|
||||
enabled: true,
|
||||
filters: [],
|
||||
target: { channelId: "123" },
|
||||
targets: [{ channelId: "123" }],
|
||||
};
|
||||
|
||||
it("matches all events when no filters", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue