@crewhaus/channel-adapter-telegram 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/package.json +41 -0
- package/src/fixtures/bot_mention.json +10 -0
- package/src/fixtures/bot_self.json +9 -0
- package/src/fixtures/callback_query.json +12 -0
- package/src/fixtures/edited_message.json +9 -0
- package/src/fixtures/group_message.json +9 -0
- package/src/fixtures/group_topic_message.json +10 -0
- package/src/fixtures/missing_chat.json +8 -0
- package/src/fixtures/non_message_update.json +7 -0
- package/src/fixtures/photo_with_caption.json +9 -0
- package/src/fixtures/private_message.json +9 -0
- package/src/fixtures/sticker_only.json +8 -0
- package/src/index.test.ts +278 -0
- package/src/index.ts +289 -0
- package/src/verify.ts +29 -0
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/channel-adapter-telegram",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Telegram channel adapter: secret_token webhook verification, message/edited_message/callback_query parsing, group + topic session keying (Section 33)",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "bun test src"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@crewhaus/errors": "0.0.0"
|
|
16
|
+
},
|
|
17
|
+
"license": "Apache-2.0",
|
|
18
|
+
"author": {
|
|
19
|
+
"name": "Max Meier",
|
|
20
|
+
"email": "max@studiomax.io",
|
|
21
|
+
"url": "https://studiomax.io"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
26
|
+
"directory": "packages/channel-adapter-telegram"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/channel-adapter-telegram#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "restricted"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"src",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"NOTICE"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"update_id": 100006,
|
|
3
|
+
"message": {
|
|
4
|
+
"message_id": 21,
|
|
5
|
+
"from": { "id": 4242, "is_bot": false, "username": "alice" },
|
|
6
|
+
"chat": { "id": -100456, "type": "supergroup" },
|
|
7
|
+
"text": "@crewhaus_bot summarize this thread",
|
|
8
|
+
"entities": [{ "type": "mention", "offset": 0, "length": 14 }]
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type ChannelAdapter,
|
|
7
|
+
type InboundEvent,
|
|
8
|
+
type ParsedInbound,
|
|
9
|
+
type RawRequest,
|
|
10
|
+
TelegramAdapterError,
|
|
11
|
+
createTelegramAdapter,
|
|
12
|
+
verifyTelegramSecret,
|
|
13
|
+
} from "./index";
|
|
14
|
+
|
|
15
|
+
// `tsc -b` also compiles this file into `dist/`; resolve fixtures from the
|
|
16
|
+
// source tree so both the src and dist test copies find them.
|
|
17
|
+
const FIXTURES_DIR = join(import.meta.dir.replace(/([/\\])dist$/, "$1src"), "fixtures");
|
|
18
|
+
const fixture = (name: string) => readFileSync(join(FIXTURES_DIR, `${name}.json`), "utf8");
|
|
19
|
+
|
|
20
|
+
const SECRET = "tg-secret-shhh";
|
|
21
|
+
const BOT_TOKEN = "1234:test-token";
|
|
22
|
+
const ADAPTER_OPTS = (extras: Record<string, unknown> = {}) => ({
|
|
23
|
+
apiBaseUrl: "https://test.telegram.local",
|
|
24
|
+
fetch: extras["fetch"] as typeof fetch | undefined,
|
|
25
|
+
selfBotId: "9999",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function withHeaders(token: string | undefined, body: string): RawRequest {
|
|
29
|
+
const h = new Headers();
|
|
30
|
+
if (token !== undefined) h.set("X-Telegram-Bot-Api-Secret-Token", token);
|
|
31
|
+
return { headers: h, body };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function adapter(extra: Record<string, unknown> = {}): ChannelAdapter {
|
|
35
|
+
return createTelegramAdapter({ botToken: BOT_TOKEN, secretToken: SECRET }, ADAPTER_OPTS(extra));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("verifyTelegramSecret (T8)", () => {
|
|
39
|
+
test("matches a valid secret token", () => {
|
|
40
|
+
const h = new Headers({ "X-Telegram-Bot-Api-Secret-Token": SECRET });
|
|
41
|
+
expect(verifyTelegramSecret({ headers: h, secretToken: SECRET })).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("rejects a tampered secret", () => {
|
|
45
|
+
const h = new Headers({ "X-Telegram-Bot-Api-Secret-Token": `${SECRET}-tampered` });
|
|
46
|
+
expect(verifyTelegramSecret({ headers: h, secretToken: SECRET })).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("rejects a missing header", () => {
|
|
50
|
+
expect(verifyTelegramSecret({ headers: new Headers(), secretToken: SECRET })).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("constant-time guard rejects different lengths", () => {
|
|
54
|
+
const h = new Headers({ "X-Telegram-Bot-Api-Secret-Token": "x" });
|
|
55
|
+
expect(verifyTelegramSecret({ headers: h, secretToken: SECRET })).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("rejects empty supplied token", () => {
|
|
59
|
+
const h = new Headers({ "X-Telegram-Bot-Api-Secret-Token": "" });
|
|
60
|
+
expect(verifyTelegramSecret({ headers: h, secretToken: SECRET })).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("createTelegramAdapter.verify()", () => {
|
|
65
|
+
test("forwards header to verifyTelegramSecret", () => {
|
|
66
|
+
const a = adapter();
|
|
67
|
+
expect(a.verify(withHeaders(SECRET, "{}"))).toBe(true);
|
|
68
|
+
expect(a.verify(withHeaders("wrong-secret-here", "{}"))).toBe(false);
|
|
69
|
+
expect(a.verify(withHeaders(undefined, "{}"))).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("parseInbound — fixtures (T2)", () => {
|
|
74
|
+
test("private_message → event with workspaceId=chat.id, channelId=chat.id", () => {
|
|
75
|
+
const a = adapter();
|
|
76
|
+
const result = a.parseInbound(withHeaders(SECRET, fixture("private_message"))) as Extract<
|
|
77
|
+
ParsedInbound,
|
|
78
|
+
{ kind: "event" }
|
|
79
|
+
>;
|
|
80
|
+
expect(result.kind).toBe("event");
|
|
81
|
+
expect(result.event.workspaceId).toBe("4242");
|
|
82
|
+
expect(result.event.channelId).toBe("4242");
|
|
83
|
+
expect(result.event.userId).toBe("4242");
|
|
84
|
+
expect(result.event.text).toBe("hello bot");
|
|
85
|
+
expect(result.event.idempotencyKey).toBe("100001");
|
|
86
|
+
expect(result.event.subtype).toBe("message");
|
|
87
|
+
expect(result.event.threadTs).toBeUndefined();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("group_message → event with workspaceId = supergroup chat.id", () => {
|
|
91
|
+
const a = adapter();
|
|
92
|
+
const r = a.parseInbound(withHeaders(SECRET, fixture("group_message"))) as Extract<
|
|
93
|
+
ParsedInbound,
|
|
94
|
+
{ kind: "event" }
|
|
95
|
+
>;
|
|
96
|
+
expect(r.kind).toBe("event");
|
|
97
|
+
expect(r.event.workspaceId).toBe("-100123");
|
|
98
|
+
expect(r.event.channelId).toBe("-100123");
|
|
99
|
+
expect(r.event.threadTs).toBeUndefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("group_topic_message → channelId = chatId:topicId, threadTs set", () => {
|
|
103
|
+
const a = adapter();
|
|
104
|
+
const r = a.parseInbound(withHeaders(SECRET, fixture("group_topic_message"))) as Extract<
|
|
105
|
+
ParsedInbound,
|
|
106
|
+
{ kind: "event" }
|
|
107
|
+
>;
|
|
108
|
+
expect(r.event.channelId).toBe("-100123:17");
|
|
109
|
+
expect(r.event.threadTs).toBe("17");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("edited_message produces a normal event (gateway dedups via update_id)", () => {
|
|
113
|
+
const a = adapter();
|
|
114
|
+
const r = a.parseInbound(withHeaders(SECRET, fixture("edited_message"))) as Extract<
|
|
115
|
+
ParsedInbound,
|
|
116
|
+
{ kind: "event" }
|
|
117
|
+
>;
|
|
118
|
+
expect(r.kind).toBe("event");
|
|
119
|
+
expect(r.event.idempotencyKey).toBe("100004");
|
|
120
|
+
expect(r.event.text).toBe("hello bot (edited)");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("callback_query → event whose text is the callback `data`", () => {
|
|
124
|
+
const a = adapter();
|
|
125
|
+
const r = a.parseInbound(withHeaders(SECRET, fixture("callback_query"))) as Extract<
|
|
126
|
+
ParsedInbound,
|
|
127
|
+
{ kind: "event" }
|
|
128
|
+
>;
|
|
129
|
+
expect(r.kind).toBe("event");
|
|
130
|
+
expect(r.event.text).toBe("approve:123");
|
|
131
|
+
expect(r.event.idempotencyKey).toBe("100005");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("bot_mention with mention entity → subtype app_mention", () => {
|
|
135
|
+
const a = adapter();
|
|
136
|
+
const r = a.parseInbound(withHeaders(SECRET, fixture("bot_mention"))) as Extract<
|
|
137
|
+
ParsedInbound,
|
|
138
|
+
{ kind: "event" }
|
|
139
|
+
>;
|
|
140
|
+
expect(r.event.subtype).toBe("app_mention");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("sticker_only (no text/caption) → skip", () => {
|
|
144
|
+
const a = adapter();
|
|
145
|
+
expect(a.parseInbound(withHeaders(SECRET, fixture("sticker_only"))).kind).toBe("skip");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("photo_with_caption → event with caption as text", () => {
|
|
149
|
+
const a = adapter();
|
|
150
|
+
const r = a.parseInbound(withHeaders(SECRET, fixture("photo_with_caption"))) as Extract<
|
|
151
|
+
ParsedInbound,
|
|
152
|
+
{ kind: "event" }
|
|
153
|
+
>;
|
|
154
|
+
expect(r.event.text).toBe("look at this graph");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("bot_self matching selfBotId → skip", () => {
|
|
158
|
+
const a = adapter();
|
|
159
|
+
expect(a.parseInbound(withHeaders(SECRET, fixture("bot_self"))).kind).toBe("skip");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("missing_chat → skip", () => {
|
|
163
|
+
const a = adapter();
|
|
164
|
+
expect(a.parseInbound(withHeaders(SECRET, fixture("missing_chat"))).kind).toBe("skip");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("non_message_update (poll) → skip", () => {
|
|
168
|
+
const a = adapter();
|
|
169
|
+
expect(a.parseInbound(withHeaders(SECRET, fixture("non_message_update"))).kind).toBe("skip");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("malformed JSON body → skip", () => {
|
|
173
|
+
const a = adapter();
|
|
174
|
+
expect(a.parseInbound(withHeaders(SECRET, "{not-json")).kind).toBe("skip");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("empty body → skip", () => {
|
|
178
|
+
const a = adapter();
|
|
179
|
+
expect(a.parseInbound(withHeaders(SECRET, "null")).kind).toBe("skip");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("sendReply / setTyping (T3)", () => {
|
|
184
|
+
function captureFetch() {
|
|
185
|
+
const calls: Array<{ url: string; init: RequestInit }> = [];
|
|
186
|
+
const f = (async (input: string | Request | URL, init?: RequestInit) => {
|
|
187
|
+
calls.push({ url: String(input), init: init ?? {} });
|
|
188
|
+
return new Response(JSON.stringify({ ok: true, result: { message_id: 999 } }), {
|
|
189
|
+
status: 200,
|
|
190
|
+
headers: { "content-type": "application/json" },
|
|
191
|
+
});
|
|
192
|
+
}) as unknown as typeof fetch;
|
|
193
|
+
return { calls, fetch: f };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const event: InboundEvent = {
|
|
197
|
+
idempotencyKey: "100001",
|
|
198
|
+
workspaceId: "4242",
|
|
199
|
+
channelId: "4242",
|
|
200
|
+
userId: "4242",
|
|
201
|
+
ts: "7",
|
|
202
|
+
text: "hello",
|
|
203
|
+
subtype: "message",
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
test("sendReply POSTs sendMessage with correct chat_id + text", async () => {
|
|
207
|
+
const { calls, fetch: f } = captureFetch();
|
|
208
|
+
const a = createTelegramAdapter(
|
|
209
|
+
{ botToken: BOT_TOKEN, secretToken: SECRET },
|
|
210
|
+
{ apiBaseUrl: "https://test.telegram.local", fetch: f },
|
|
211
|
+
);
|
|
212
|
+
await a.sendReply({ event, text: "hi back" });
|
|
213
|
+
expect(calls.length).toBe(1);
|
|
214
|
+
expect(calls[0]?.url).toBe(`https://test.telegram.local/bot${BOT_TOKEN}/sendMessage`);
|
|
215
|
+
const body = JSON.parse(String(calls[0]?.init.body));
|
|
216
|
+
expect(body.chat_id).toBe(4242);
|
|
217
|
+
expect(body.text).toBe("hi back");
|
|
218
|
+
expect(body.message_thread_id).toBeUndefined();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("sendReply forwards message_thread_id when threadTs set", async () => {
|
|
222
|
+
const { calls, fetch: f } = captureFetch();
|
|
223
|
+
const a = createTelegramAdapter(
|
|
224
|
+
{ botToken: BOT_TOKEN, secretToken: SECRET },
|
|
225
|
+
{ apiBaseUrl: "https://test.telegram.local", fetch: f },
|
|
226
|
+
);
|
|
227
|
+
await a.sendReply({
|
|
228
|
+
event: { ...event, threadTs: "17", channelId: "4242:17" },
|
|
229
|
+
text: "topic reply",
|
|
230
|
+
});
|
|
231
|
+
const body = JSON.parse(String(calls[0]?.init.body));
|
|
232
|
+
expect(body.message_thread_id).toBe(17);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("sendReply throws on HTTP error", async () => {
|
|
236
|
+
const f = (async () =>
|
|
237
|
+
new Response("boom", { status: 502, statusText: "Bad Gateway" })) as unknown as typeof fetch;
|
|
238
|
+
const a = createTelegramAdapter(
|
|
239
|
+
{ botToken: BOT_TOKEN, secretToken: SECRET },
|
|
240
|
+
{ apiBaseUrl: "https://test.telegram.local", fetch: f },
|
|
241
|
+
);
|
|
242
|
+
await expect(a.sendReply({ event, text: "x" })).rejects.toThrow(TelegramAdapterError);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("sendReply throws on Telegram-side ok:false", async () => {
|
|
246
|
+
const f = (async () =>
|
|
247
|
+
new Response(JSON.stringify({ ok: false, description: "chat not found" }), {
|
|
248
|
+
status: 200,
|
|
249
|
+
headers: { "content-type": "application/json" },
|
|
250
|
+
})) as unknown as typeof fetch;
|
|
251
|
+
const a = createTelegramAdapter(
|
|
252
|
+
{ botToken: BOT_TOKEN, secretToken: SECRET },
|
|
253
|
+
{ apiBaseUrl: "https://test.telegram.local", fetch: f },
|
|
254
|
+
);
|
|
255
|
+
await expect(a.sendReply({ event, text: "x" })).rejects.toThrow(/chat not found/);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("sendReply rejects malformed workspaceId", async () => {
|
|
259
|
+
const a = adapter();
|
|
260
|
+
await expect(
|
|
261
|
+
a.sendReply({ event: { ...event, workspaceId: "not-a-number" }, text: "x" }),
|
|
262
|
+
).rejects.toThrow(/invalid workspaceId/);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("setTyping POSTs sendChatAction with action=typing", async () => {
|
|
266
|
+
const { calls, fetch: f } = captureFetch();
|
|
267
|
+
const a = createTelegramAdapter(
|
|
268
|
+
{ botToken: BOT_TOKEN, secretToken: SECRET },
|
|
269
|
+
{ apiBaseUrl: "https://test.telegram.local", fetch: f },
|
|
270
|
+
);
|
|
271
|
+
await a.setTyping({ event });
|
|
272
|
+
expect(calls.length).toBe(1);
|
|
273
|
+
expect(calls[0]?.url).toBe(`https://test.telegram.local/bot${BOT_TOKEN}/sendChatAction`);
|
|
274
|
+
const body = JSON.parse(String(calls[0]?.init.body));
|
|
275
|
+
expect(body.chat_id).toBe(4242);
|
|
276
|
+
expect(body.action).toBe("typing");
|
|
277
|
+
});
|
|
278
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @crewhaus/channel-adapter-telegram — Telegram channel adapter for the
|
|
3
|
+
* channel target (Section 33).
|
|
4
|
+
*
|
|
5
|
+
* Implements the same `ChannelAdapter` contract used by
|
|
6
|
+
* @crewhaus/channel-adapter-slack — verify(), parseInbound(), sendReply(),
|
|
7
|
+
* setTyping(). The Section 12 daemon registers this adapter alongside
|
|
8
|
+
* Slack and any other channel adapter, with the gateway dispatching
|
|
9
|
+
* inbound webhooks to whichever adapter id matches.
|
|
10
|
+
*
|
|
11
|
+
* Verification: `X-Telegram-Bot-Api-Secret-Token` header (set via
|
|
12
|
+
* `setWebhook(secret_token=...)`) compared timing-safely to the configured
|
|
13
|
+
* secret. Telegram does NOT sign the body — the secret token alone
|
|
14
|
+
* authenticates.
|
|
15
|
+
*
|
|
16
|
+
* Parse: handles `message`, `edited_message`, and `callback_query`
|
|
17
|
+
* (button-press) Update payloads. Group-chat session keying uses
|
|
18
|
+
* `<chatId>:<topicId>` when topics are enabled (chat type "supergroup"
|
|
19
|
+
* with `message_thread_id` present); otherwise just `<chatId>`.
|
|
20
|
+
*
|
|
21
|
+
* sendReply: POST to `https://api.telegram.org/bot<token>/sendMessage`.
|
|
22
|
+
* setTyping: POST `sendChatAction` with `action=typing`.
|
|
23
|
+
*/
|
|
24
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
25
|
+
import { verifyTelegramSecret } from "./verify.js";
|
|
26
|
+
|
|
27
|
+
export { verifyTelegramSecret } from "./verify.js";
|
|
28
|
+
|
|
29
|
+
export class TelegramAdapterError extends CrewhausError {
|
|
30
|
+
override readonly name = "TelegramAdapterError";
|
|
31
|
+
constructor(message: string, cause?: unknown) {
|
|
32
|
+
super("channel", message, cause);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type RawRequest = {
|
|
37
|
+
readonly headers: Headers;
|
|
38
|
+
readonly body: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Channel-generic inbound event. Same shape as the Slack adapter's
|
|
43
|
+
* `InboundEvent` so the gateway and session-router stay channel-agnostic.
|
|
44
|
+
*
|
|
45
|
+
* Mappings:
|
|
46
|
+
* - workspaceId → chat.id (Telegram has no "workspace"; we reuse the
|
|
47
|
+
* field for chat-level grouping)
|
|
48
|
+
* - channelId → chat.id : topicId (group-chat thread scope) or
|
|
49
|
+
* chat.id alone for private/group chats
|
|
50
|
+
* - userId → from.id (numeric, stringified)
|
|
51
|
+
* - threadTs → message_thread_id when present
|
|
52
|
+
* - ts → message_id (stringified) — monotonic per chat
|
|
53
|
+
* - text → message.text or callback_query.data
|
|
54
|
+
* - subtype → "message" | "app_mention"
|
|
55
|
+
* - idempotencyKey → update_id (stringified)
|
|
56
|
+
*/
|
|
57
|
+
export type InboundEvent = {
|
|
58
|
+
readonly idempotencyKey: string;
|
|
59
|
+
readonly workspaceId: string;
|
|
60
|
+
readonly channelId: string;
|
|
61
|
+
readonly userId: string;
|
|
62
|
+
readonly threadTs?: string;
|
|
63
|
+
readonly ts: string;
|
|
64
|
+
readonly text: string;
|
|
65
|
+
readonly subtype: "app_mention" | "message";
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type ParsedInbound =
|
|
69
|
+
| { readonly kind: "event"; readonly event: InboundEvent }
|
|
70
|
+
| { readonly kind: "skip" };
|
|
71
|
+
|
|
72
|
+
export interface ChannelAdapter {
|
|
73
|
+
readonly id: string;
|
|
74
|
+
verify(req: RawRequest): boolean;
|
|
75
|
+
parseInbound(req: RawRequest): ParsedInbound;
|
|
76
|
+
sendReply(args: { event: InboundEvent; text: string }): Promise<void>;
|
|
77
|
+
setTyping(args: { event: InboundEvent }): Promise<void>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type TelegramAdapterConfig = {
|
|
81
|
+
readonly botToken: string;
|
|
82
|
+
readonly secretToken: string;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export type TelegramAdapterOptions = {
|
|
86
|
+
readonly apiBaseUrl?: string;
|
|
87
|
+
readonly fetch?: typeof fetch;
|
|
88
|
+
readonly selfBotId?: string;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const DEFAULT_API_BASE_URL = "https://api.telegram.org";
|
|
92
|
+
|
|
93
|
+
export function createTelegramAdapter(
|
|
94
|
+
config: TelegramAdapterConfig,
|
|
95
|
+
opts: TelegramAdapterOptions = {},
|
|
96
|
+
): ChannelAdapter {
|
|
97
|
+
const apiBaseUrl =
|
|
98
|
+
opts.apiBaseUrl ?? process.env["TELEGRAM_API_BASE_URL"] ?? DEFAULT_API_BASE_URL;
|
|
99
|
+
const doFetch = opts.fetch ?? fetch;
|
|
100
|
+
const botEndpoint = (method: string) => `${apiBaseUrl}/bot${config.botToken}/${method}`;
|
|
101
|
+
|
|
102
|
+
function inboundFromMessage(
|
|
103
|
+
update: TelegramUpdate,
|
|
104
|
+
msg: TelegramMessage,
|
|
105
|
+
subtype: "message" | "app_mention",
|
|
106
|
+
): ParsedInbound {
|
|
107
|
+
const chatId = msg.chat?.id;
|
|
108
|
+
const userId = msg.from?.id;
|
|
109
|
+
const text = msg.text ?? msg.caption ?? "";
|
|
110
|
+
if (chatId === undefined || userId === undefined) return { kind: "skip" };
|
|
111
|
+
if (
|
|
112
|
+
opts.selfBotId !== undefined &&
|
|
113
|
+
msg.from?.is_bot &&
|
|
114
|
+
String(msg.from.id) === opts.selfBotId
|
|
115
|
+
) {
|
|
116
|
+
return { kind: "skip" };
|
|
117
|
+
}
|
|
118
|
+
const threadId = msg.message_thread_id;
|
|
119
|
+
const channelId = threadId !== undefined ? `${chatId}:${threadId}` : String(chatId);
|
|
120
|
+
const event: InboundEvent = {
|
|
121
|
+
idempotencyKey: String(update.update_id),
|
|
122
|
+
workspaceId: String(chatId),
|
|
123
|
+
channelId,
|
|
124
|
+
userId: String(userId),
|
|
125
|
+
...(threadId !== undefined ? { threadTs: String(threadId) } : {}),
|
|
126
|
+
ts: String(msg.message_id),
|
|
127
|
+
text,
|
|
128
|
+
subtype,
|
|
129
|
+
};
|
|
130
|
+
return { kind: "event", event };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
id: "telegram",
|
|
135
|
+
|
|
136
|
+
verify(req: RawRequest): boolean {
|
|
137
|
+
return verifyTelegramSecret({ headers: req.headers, secretToken: config.secretToken });
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
parseInbound(req: RawRequest): ParsedInbound {
|
|
141
|
+
let payload: unknown;
|
|
142
|
+
try {
|
|
143
|
+
payload = JSON.parse(req.body);
|
|
144
|
+
} catch {
|
|
145
|
+
return { kind: "skip" };
|
|
146
|
+
}
|
|
147
|
+
if (typeof payload !== "object" || payload === null) return { kind: "skip" };
|
|
148
|
+
const update = payload as TelegramUpdate;
|
|
149
|
+
if (typeof update.update_id !== "number") return { kind: "skip" };
|
|
150
|
+
|
|
151
|
+
// Determine which slot is populated. Telegram updates are mutually
|
|
152
|
+
// exclusive over message / edited_message / channel_post / callback_query.
|
|
153
|
+
if (update.message) {
|
|
154
|
+
const msg = update.message;
|
|
155
|
+
// Skip empty messages (sticker-only / photo-only / system messages)
|
|
156
|
+
if (!msg.text && !msg.caption) return { kind: "skip" };
|
|
157
|
+
// Detect bot mention via entities (`type: "mention"` or `type: "bot_command"`).
|
|
158
|
+
const isMention =
|
|
159
|
+
msg.entities?.some((e) => e.type === "mention" || e.type === "bot_command") ?? false;
|
|
160
|
+
return inboundFromMessage(update, msg, isMention ? "app_mention" : "message");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (update.edited_message) {
|
|
164
|
+
// Treat edits as new inbound events (gateway dedups on update_id).
|
|
165
|
+
const msg = update.edited_message;
|
|
166
|
+
if (!msg.text && !msg.caption) return { kind: "skip" };
|
|
167
|
+
return inboundFromMessage(update, msg, "message");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (update.callback_query) {
|
|
171
|
+
const cq = update.callback_query;
|
|
172
|
+
const msg = cq.message;
|
|
173
|
+
const userId = cq.from?.id;
|
|
174
|
+
const data = cq.data ?? "";
|
|
175
|
+
if (!msg || userId === undefined) return { kind: "skip" };
|
|
176
|
+
const chatId = msg.chat?.id;
|
|
177
|
+
if (chatId === undefined) return { kind: "skip" };
|
|
178
|
+
const threadId = msg.message_thread_id;
|
|
179
|
+
const channelId = threadId !== undefined ? `${chatId}:${threadId}` : String(chatId);
|
|
180
|
+
const event: InboundEvent = {
|
|
181
|
+
idempotencyKey: String(update.update_id),
|
|
182
|
+
workspaceId: String(chatId),
|
|
183
|
+
channelId,
|
|
184
|
+
userId: String(userId),
|
|
185
|
+
...(threadId !== undefined ? { threadTs: String(threadId) } : {}),
|
|
186
|
+
ts: String(msg.message_id),
|
|
187
|
+
text: data,
|
|
188
|
+
subtype: "message",
|
|
189
|
+
};
|
|
190
|
+
return { kind: "event", event };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { kind: "skip" };
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
async sendReply(args: { event: InboundEvent; text: string }): Promise<void> {
|
|
197
|
+
const url = botEndpoint("sendMessage");
|
|
198
|
+
// Reconstruct chat_id from the workspaceId field (always the raw chat.id).
|
|
199
|
+
const chatId = Number.parseInt(args.event.workspaceId, 10);
|
|
200
|
+
if (!Number.isFinite(chatId)) {
|
|
201
|
+
throw new TelegramAdapterError(`invalid workspaceId in event: ${args.event.workspaceId}`);
|
|
202
|
+
}
|
|
203
|
+
const body: Record<string, unknown> = {
|
|
204
|
+
chat_id: chatId,
|
|
205
|
+
text: args.text,
|
|
206
|
+
};
|
|
207
|
+
const threadId = args.event.threadTs;
|
|
208
|
+
if (threadId) {
|
|
209
|
+
body["message_thread_id"] = Number.parseInt(threadId, 10);
|
|
210
|
+
}
|
|
211
|
+
const res = await doFetch(url, {
|
|
212
|
+
method: "POST",
|
|
213
|
+
headers: { "Content-Type": "application/json" },
|
|
214
|
+
body: JSON.stringify(body),
|
|
215
|
+
});
|
|
216
|
+
if (!res.ok) {
|
|
217
|
+
throw new TelegramAdapterError(`sendMessage failed: ${res.status} ${res.statusText}`);
|
|
218
|
+
}
|
|
219
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
220
|
+
if (ct.includes("application/json")) {
|
|
221
|
+
const json = (await res.json()) as { ok?: boolean; description?: string };
|
|
222
|
+
if (json.ok === false) {
|
|
223
|
+
throw new TelegramAdapterError(`sendMessage error: ${json.description ?? "unknown"}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
async setTyping(args: { event: InboundEvent }): Promise<void> {
|
|
229
|
+
const url = botEndpoint("sendChatAction");
|
|
230
|
+
const chatId = Number.parseInt(args.event.workspaceId, 10);
|
|
231
|
+
if (!Number.isFinite(chatId)) {
|
|
232
|
+
throw new TelegramAdapterError(`invalid workspaceId in event: ${args.event.workspaceId}`);
|
|
233
|
+
}
|
|
234
|
+
const body: Record<string, unknown> = { chat_id: chatId, action: "typing" };
|
|
235
|
+
if (args.event.threadTs) {
|
|
236
|
+
body["message_thread_id"] = Number.parseInt(args.event.threadTs, 10);
|
|
237
|
+
}
|
|
238
|
+
await doFetch(url, {
|
|
239
|
+
method: "POST",
|
|
240
|
+
headers: { "Content-Type": "application/json" },
|
|
241
|
+
body: JSON.stringify(body),
|
|
242
|
+
});
|
|
243
|
+
// setTyping is best-effort; a non-200 here should not fail the run.
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ─── Telegram Bot API minimal types (no SDK dep) ─────────────────────────────
|
|
249
|
+
|
|
250
|
+
export type TelegramUpdate = {
|
|
251
|
+
readonly update_id: number;
|
|
252
|
+
readonly message?: TelegramMessage;
|
|
253
|
+
readonly edited_message?: TelegramMessage;
|
|
254
|
+
readonly callback_query?: TelegramCallbackQuery;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
export type TelegramMessage = {
|
|
258
|
+
readonly message_id: number;
|
|
259
|
+
readonly from?: TelegramUser;
|
|
260
|
+
readonly chat: TelegramChat;
|
|
261
|
+
readonly text?: string;
|
|
262
|
+
readonly caption?: string;
|
|
263
|
+
readonly message_thread_id?: number;
|
|
264
|
+
readonly entities?: ReadonlyArray<TelegramMessageEntity>;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
export type TelegramUser = {
|
|
268
|
+
readonly id: number;
|
|
269
|
+
readonly is_bot?: boolean;
|
|
270
|
+
readonly username?: string;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
export type TelegramChat = {
|
|
274
|
+
readonly id: number;
|
|
275
|
+
readonly type?: "private" | "group" | "supergroup" | "channel";
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
export type TelegramMessageEntity = {
|
|
279
|
+
readonly type: string;
|
|
280
|
+
readonly offset?: number;
|
|
281
|
+
readonly length?: number;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
export type TelegramCallbackQuery = {
|
|
285
|
+
readonly id: string;
|
|
286
|
+
readonly from: TelegramUser;
|
|
287
|
+
readonly data?: string;
|
|
288
|
+
readonly message?: TelegramMessage;
|
|
289
|
+
};
|
package/src/verify.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Telegram webhook verification.
|
|
5
|
+
*
|
|
6
|
+
* Telegram authenticates webhooks by sending the secret token (configured
|
|
7
|
+
* via `setWebhook(secret_token=...)` on the Bot API) in the
|
|
8
|
+
* `X-Telegram-Bot-Api-Secret-Token` header on every POST. We compare it
|
|
9
|
+
* to the secret we hold using `timingSafeEqual` to avoid nibble-leak
|
|
10
|
+
* side channels.
|
|
11
|
+
*
|
|
12
|
+
* Telegram does not sign the body, so there is no replay-window check
|
|
13
|
+
* (the secret token alone authenticates). The Bot API secret is
|
|
14
|
+
* 1–256 chars, A–Z / a–z / 0–9 / _ / -.
|
|
15
|
+
*/
|
|
16
|
+
export type TelegramVerifyArgs = {
|
|
17
|
+
readonly headers: Headers;
|
|
18
|
+
readonly secretToken: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function verifyTelegramSecret(args: TelegramVerifyArgs): boolean {
|
|
22
|
+
const supplied = args.headers.get("x-telegram-bot-api-secret-token");
|
|
23
|
+
if (!supplied) return false;
|
|
24
|
+
if (supplied.length !== args.secretToken.length) return false;
|
|
25
|
+
const a = Buffer.from(supplied, "utf8");
|
|
26
|
+
const b = Buffer.from(args.secretToken, "utf8");
|
|
27
|
+
if (a.length !== b.length) return false;
|
|
28
|
+
return timingSafeEqual(a, b);
|
|
29
|
+
}
|