easy-ping 0.6.1 → 0.7.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/README.md +20 -4
- package/dist/{adapter-D3lTy-4E.d.cts → adapter-BJ8Iejvs.d.cts} +1 -1
- package/dist/{adapter-D3lTy-4E.d.ts → adapter-BJ8Iejvs.d.ts} +1 -1
- package/dist/adapters/drizzle.d.cts +1 -1
- package/dist/adapters/drizzle.d.ts +1 -1
- package/dist/adapters/mongodb.d.cts +2 -2
- package/dist/adapters/mongodb.d.ts +2 -2
- package/dist/adapters/mysql.d.cts +2 -2
- package/dist/adapters/mysql.d.ts +2 -2
- package/dist/adapters/postgres.d.cts +2 -2
- package/dist/adapters/postgres.d.ts +2 -2
- package/dist/adapters/sqlite.d.cts +2 -2
- package/dist/adapters/sqlite.d.ts +2 -2
- package/dist/chunk-R3BE67L7.js +13 -0
- package/dist/chunk-R3BE67L7.js.map +1 -0
- package/dist/chunk-SSHVZHSJ.js +100 -0
- package/dist/chunk-SSHVZHSJ.js.map +1 -0
- package/dist/index.d.cts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +2 -11
- package/dist/index.js.map +1 -1
- package/dist/{plugin-Cg9usuUi.d.ts → plugin-CSLKKgXT.d.ts} +1 -1
- package/dist/{plugin-CHd2ZiGC.d.cts → plugin-Wwp1nvo0.d.cts} +1 -1
- package/dist/plugins/digests.d.cts +2 -2
- package/dist/plugins/digests.d.ts +2 -2
- package/dist/plugins/preferences-client.d.cts +2 -2
- package/dist/plugins/preferences-client.d.ts +2 -2
- package/dist/plugins/preferences-react.d.cts +1 -1
- package/dist/plugins/preferences-react.d.ts +1 -1
- package/dist/plugins/preferences.cjs +1 -1
- package/dist/plugins/preferences.cjs.map +1 -1
- package/dist/plugins/preferences.d.cts +2 -2
- package/dist/plugins/preferences.d.ts +2 -2
- package/dist/plugins/preferences.js +1 -1
- package/dist/plugins/preferences.js.map +1 -1
- package/dist/plugins/push.d.cts +2 -2
- package/dist/plugins/push.d.ts +2 -2
- package/dist/plugins/telegram.cjs +379 -0
- package/dist/plugins/telegram.cjs.map +1 -0
- package/dist/plugins/telegram.d.cts +153 -0
- package/dist/plugins/telegram.d.ts +153 -0
- package/dist/plugins/telegram.js +313 -0
- package/dist/plugins/telegram.js.map +1 -0
- package/dist/providers/telegram.cjs +103 -0
- package/dist/providers/telegram.cjs.map +1 -0
- package/dist/providers/telegram.d.cts +83 -0
- package/dist/providers/telegram.d.ts +83 -0
- package/dist/providers/telegram.js +3 -0
- package/dist/providers/telegram.js.map +1 -0
- package/dist/providers/web-push.d.cts +2 -2
- package/dist/providers/web-push.d.ts +2 -2
- package/dist/schema.d.cts +2 -2
- package/dist/schema.d.ts +2 -2
- package/dist/testing.d.cts +1 -1
- package/dist/testing.d.ts +1 -1
- package/package.json +13 -2
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { h as EasyPingPlugin, L as Logger } from '../plugin-Wwp1nvo0.cjs';
|
|
2
|
+
import { TelegramProvider, TelegramUpdate } from '../providers/telegram.cjs';
|
|
3
|
+
import '../adapter-BJ8Iejvs.cjs';
|
|
4
|
+
|
|
5
|
+
type TelegramChat = {
|
|
6
|
+
id: string;
|
|
7
|
+
userId: string;
|
|
8
|
+
chatId: string;
|
|
9
|
+
/** "private", "group", "supergroup" or "channel". */
|
|
10
|
+
chatType: string;
|
|
11
|
+
username: string | null;
|
|
12
|
+
title: string | null;
|
|
13
|
+
linkedAt: Date;
|
|
14
|
+
lastSeenAt: Date;
|
|
15
|
+
};
|
|
16
|
+
type TelegramLink = {
|
|
17
|
+
code: string;
|
|
18
|
+
userId: string;
|
|
19
|
+
createdAt: Date;
|
|
20
|
+
expiresAt: Date;
|
|
21
|
+
};
|
|
22
|
+
type TelegramRendered = {
|
|
23
|
+
/** Telegram HTML. Anything user-typed inside it goes through escapeHtml(). */
|
|
24
|
+
text: string;
|
|
25
|
+
/** Adds one inline button under the message. */
|
|
26
|
+
button?: {
|
|
27
|
+
text: string;
|
|
28
|
+
url: string;
|
|
29
|
+
} | undefined;
|
|
30
|
+
};
|
|
31
|
+
type TelegramOptions = {
|
|
32
|
+
provider: TelegramProvider;
|
|
33
|
+
/** Without the @. Builds the https://t.me/<bot>?start=<code> link. */
|
|
34
|
+
botUsername: string;
|
|
35
|
+
render: (input: {
|
|
36
|
+
type: string;
|
|
37
|
+
payload: unknown;
|
|
38
|
+
}) => TelegramRendered;
|
|
39
|
+
/**
|
|
40
|
+
* Shared with Telegram through setWebhook; Telegram echoes it in
|
|
41
|
+
* X-Telegram-Bot-Api-Secret-Token and the webhook route accepts nothing else.
|
|
42
|
+
* Unset, the route is not mounted and updates must come through poll().
|
|
43
|
+
*/
|
|
44
|
+
webhookSecret?: string | undefined;
|
|
45
|
+
/** How long a "Connect Telegram" link stays valid. Default 10 minutes. */
|
|
46
|
+
linkTtlMinutes?: number | undefined;
|
|
47
|
+
/** Linking past this evicts the user's least recently seen chat. Default 5. */
|
|
48
|
+
maxChatsPerUser?: number | undefined;
|
|
49
|
+
/** Bot replies, if the defaults are not your voice. */
|
|
50
|
+
messages?: Partial<typeof DEFAULT_MESSAGES> | undefined;
|
|
51
|
+
};
|
|
52
|
+
declare const DEFAULT_MESSAGES: {
|
|
53
|
+
connected: string;
|
|
54
|
+
disconnected: string;
|
|
55
|
+
expired: string;
|
|
56
|
+
start: string;
|
|
57
|
+
};
|
|
58
|
+
/** The tables this plugin owns, exported so DDL needs no plugin instance. */
|
|
59
|
+
declare const telegramSchema: {
|
|
60
|
+
readonly telegramChat: {
|
|
61
|
+
readonly tableName: "notification_telegram_chat";
|
|
62
|
+
readonly fields: {
|
|
63
|
+
readonly id: {
|
|
64
|
+
readonly type: "string";
|
|
65
|
+
readonly required: true;
|
|
66
|
+
};
|
|
67
|
+
readonly userId: {
|
|
68
|
+
readonly type: "string";
|
|
69
|
+
readonly required: true;
|
|
70
|
+
};
|
|
71
|
+
readonly chatId: {
|
|
72
|
+
readonly type: "string";
|
|
73
|
+
readonly required: true;
|
|
74
|
+
};
|
|
75
|
+
readonly chatType: {
|
|
76
|
+
readonly type: "string";
|
|
77
|
+
readonly required: true;
|
|
78
|
+
};
|
|
79
|
+
readonly username: {
|
|
80
|
+
readonly type: "string";
|
|
81
|
+
};
|
|
82
|
+
readonly title: {
|
|
83
|
+
readonly type: "string";
|
|
84
|
+
};
|
|
85
|
+
readonly linkedAt: {
|
|
86
|
+
readonly type: "date";
|
|
87
|
+
readonly required: true;
|
|
88
|
+
readonly defaultNow: true;
|
|
89
|
+
};
|
|
90
|
+
readonly lastSeenAt: {
|
|
91
|
+
readonly type: "date";
|
|
92
|
+
readonly required: true;
|
|
93
|
+
readonly defaultNow: true;
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
readonly primaryKey: readonly ["id"];
|
|
97
|
+
readonly indexes: readonly [{
|
|
98
|
+
readonly on: readonly ["userId"];
|
|
99
|
+
readonly name: "telegram_chat_user_idx";
|
|
100
|
+
}, {
|
|
101
|
+
readonly on: readonly ["chatId"];
|
|
102
|
+
readonly unique: true;
|
|
103
|
+
readonly name: "telegram_chat_chat_idx";
|
|
104
|
+
}];
|
|
105
|
+
};
|
|
106
|
+
readonly telegramLink: {
|
|
107
|
+
readonly tableName: "notification_telegram_link";
|
|
108
|
+
readonly fields: {
|
|
109
|
+
readonly code: {
|
|
110
|
+
readonly type: "string";
|
|
111
|
+
readonly required: true;
|
|
112
|
+
};
|
|
113
|
+
readonly userId: {
|
|
114
|
+
readonly type: "string";
|
|
115
|
+
readonly required: true;
|
|
116
|
+
};
|
|
117
|
+
readonly createdAt: {
|
|
118
|
+
readonly type: "date";
|
|
119
|
+
readonly required: true;
|
|
120
|
+
readonly defaultNow: true;
|
|
121
|
+
};
|
|
122
|
+
readonly expiresAt: {
|
|
123
|
+
readonly type: "date";
|
|
124
|
+
readonly required: true;
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
readonly primaryKey: readonly ["code"];
|
|
128
|
+
readonly indexes: readonly [{
|
|
129
|
+
readonly on: readonly ["userId"];
|
|
130
|
+
readonly name: "telegram_link_user_idx";
|
|
131
|
+
}];
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
/** Telegram refuses buttons to localhost, private hosts and non-http schemes with a 400 for the whole message. */
|
|
135
|
+
declare function isButtonUrlAllowed(value: string): boolean;
|
|
136
|
+
type TelegramPlugin = EasyPingPlugin<"telegram", typeof telegramSchema> & {
|
|
137
|
+
/** Feeds one Bot API update through the linking logic. The webhook route and poll() both call it. */
|
|
138
|
+
handleUpdate(update: TelegramUpdate): Promise<void>;
|
|
139
|
+
/**
|
|
140
|
+
* Long-polls getUpdates instead of a webhook: for development, or a host
|
|
141
|
+
* with no public URL. One poller per bot token, or Telegram splits updates
|
|
142
|
+
* between them. Call stop() on shutdown.
|
|
143
|
+
*/
|
|
144
|
+
poll(options?: {
|
|
145
|
+
logger?: Logger | undefined;
|
|
146
|
+
}): {
|
|
147
|
+
stop(): void;
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
/** Telegram over a bot the user connects with one tap. Fans out per linked chat; blocked chats are pruned. */
|
|
151
|
+
declare function telegram(options: TelegramOptions): TelegramPlugin;
|
|
152
|
+
|
|
153
|
+
export { type TelegramChat, type TelegramLink, type TelegramOptions, type TelegramPlugin, type TelegramRendered, isButtonUrlAllowed, telegram, telegramSchema };
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { h as EasyPingPlugin, L as Logger } from '../plugin-CSLKKgXT.js';
|
|
2
|
+
import { TelegramProvider, TelegramUpdate } from '../providers/telegram.js';
|
|
3
|
+
import '../adapter-BJ8Iejvs.js';
|
|
4
|
+
|
|
5
|
+
type TelegramChat = {
|
|
6
|
+
id: string;
|
|
7
|
+
userId: string;
|
|
8
|
+
chatId: string;
|
|
9
|
+
/** "private", "group", "supergroup" or "channel". */
|
|
10
|
+
chatType: string;
|
|
11
|
+
username: string | null;
|
|
12
|
+
title: string | null;
|
|
13
|
+
linkedAt: Date;
|
|
14
|
+
lastSeenAt: Date;
|
|
15
|
+
};
|
|
16
|
+
type TelegramLink = {
|
|
17
|
+
code: string;
|
|
18
|
+
userId: string;
|
|
19
|
+
createdAt: Date;
|
|
20
|
+
expiresAt: Date;
|
|
21
|
+
};
|
|
22
|
+
type TelegramRendered = {
|
|
23
|
+
/** Telegram HTML. Anything user-typed inside it goes through escapeHtml(). */
|
|
24
|
+
text: string;
|
|
25
|
+
/** Adds one inline button under the message. */
|
|
26
|
+
button?: {
|
|
27
|
+
text: string;
|
|
28
|
+
url: string;
|
|
29
|
+
} | undefined;
|
|
30
|
+
};
|
|
31
|
+
type TelegramOptions = {
|
|
32
|
+
provider: TelegramProvider;
|
|
33
|
+
/** Without the @. Builds the https://t.me/<bot>?start=<code> link. */
|
|
34
|
+
botUsername: string;
|
|
35
|
+
render: (input: {
|
|
36
|
+
type: string;
|
|
37
|
+
payload: unknown;
|
|
38
|
+
}) => TelegramRendered;
|
|
39
|
+
/**
|
|
40
|
+
* Shared with Telegram through setWebhook; Telegram echoes it in
|
|
41
|
+
* X-Telegram-Bot-Api-Secret-Token and the webhook route accepts nothing else.
|
|
42
|
+
* Unset, the route is not mounted and updates must come through poll().
|
|
43
|
+
*/
|
|
44
|
+
webhookSecret?: string | undefined;
|
|
45
|
+
/** How long a "Connect Telegram" link stays valid. Default 10 minutes. */
|
|
46
|
+
linkTtlMinutes?: number | undefined;
|
|
47
|
+
/** Linking past this evicts the user's least recently seen chat. Default 5. */
|
|
48
|
+
maxChatsPerUser?: number | undefined;
|
|
49
|
+
/** Bot replies, if the defaults are not your voice. */
|
|
50
|
+
messages?: Partial<typeof DEFAULT_MESSAGES> | undefined;
|
|
51
|
+
};
|
|
52
|
+
declare const DEFAULT_MESSAGES: {
|
|
53
|
+
connected: string;
|
|
54
|
+
disconnected: string;
|
|
55
|
+
expired: string;
|
|
56
|
+
start: string;
|
|
57
|
+
};
|
|
58
|
+
/** The tables this plugin owns, exported so DDL needs no plugin instance. */
|
|
59
|
+
declare const telegramSchema: {
|
|
60
|
+
readonly telegramChat: {
|
|
61
|
+
readonly tableName: "notification_telegram_chat";
|
|
62
|
+
readonly fields: {
|
|
63
|
+
readonly id: {
|
|
64
|
+
readonly type: "string";
|
|
65
|
+
readonly required: true;
|
|
66
|
+
};
|
|
67
|
+
readonly userId: {
|
|
68
|
+
readonly type: "string";
|
|
69
|
+
readonly required: true;
|
|
70
|
+
};
|
|
71
|
+
readonly chatId: {
|
|
72
|
+
readonly type: "string";
|
|
73
|
+
readonly required: true;
|
|
74
|
+
};
|
|
75
|
+
readonly chatType: {
|
|
76
|
+
readonly type: "string";
|
|
77
|
+
readonly required: true;
|
|
78
|
+
};
|
|
79
|
+
readonly username: {
|
|
80
|
+
readonly type: "string";
|
|
81
|
+
};
|
|
82
|
+
readonly title: {
|
|
83
|
+
readonly type: "string";
|
|
84
|
+
};
|
|
85
|
+
readonly linkedAt: {
|
|
86
|
+
readonly type: "date";
|
|
87
|
+
readonly required: true;
|
|
88
|
+
readonly defaultNow: true;
|
|
89
|
+
};
|
|
90
|
+
readonly lastSeenAt: {
|
|
91
|
+
readonly type: "date";
|
|
92
|
+
readonly required: true;
|
|
93
|
+
readonly defaultNow: true;
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
readonly primaryKey: readonly ["id"];
|
|
97
|
+
readonly indexes: readonly [{
|
|
98
|
+
readonly on: readonly ["userId"];
|
|
99
|
+
readonly name: "telegram_chat_user_idx";
|
|
100
|
+
}, {
|
|
101
|
+
readonly on: readonly ["chatId"];
|
|
102
|
+
readonly unique: true;
|
|
103
|
+
readonly name: "telegram_chat_chat_idx";
|
|
104
|
+
}];
|
|
105
|
+
};
|
|
106
|
+
readonly telegramLink: {
|
|
107
|
+
readonly tableName: "notification_telegram_link";
|
|
108
|
+
readonly fields: {
|
|
109
|
+
readonly code: {
|
|
110
|
+
readonly type: "string";
|
|
111
|
+
readonly required: true;
|
|
112
|
+
};
|
|
113
|
+
readonly userId: {
|
|
114
|
+
readonly type: "string";
|
|
115
|
+
readonly required: true;
|
|
116
|
+
};
|
|
117
|
+
readonly createdAt: {
|
|
118
|
+
readonly type: "date";
|
|
119
|
+
readonly required: true;
|
|
120
|
+
readonly defaultNow: true;
|
|
121
|
+
};
|
|
122
|
+
readonly expiresAt: {
|
|
123
|
+
readonly type: "date";
|
|
124
|
+
readonly required: true;
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
readonly primaryKey: readonly ["code"];
|
|
128
|
+
readonly indexes: readonly [{
|
|
129
|
+
readonly on: readonly ["userId"];
|
|
130
|
+
readonly name: "telegram_link_user_idx";
|
|
131
|
+
}];
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
/** Telegram refuses buttons to localhost, private hosts and non-http schemes with a 400 for the whole message. */
|
|
135
|
+
declare function isButtonUrlAllowed(value: string): boolean;
|
|
136
|
+
type TelegramPlugin = EasyPingPlugin<"telegram", typeof telegramSchema> & {
|
|
137
|
+
/** Feeds one Bot API update through the linking logic. The webhook route and poll() both call it. */
|
|
138
|
+
handleUpdate(update: TelegramUpdate): Promise<void>;
|
|
139
|
+
/**
|
|
140
|
+
* Long-polls getUpdates instead of a webhook: for development, or a host
|
|
141
|
+
* with no public URL. One poller per bot token, or Telegram splits updates
|
|
142
|
+
* between them. Call stop() on shutdown.
|
|
143
|
+
*/
|
|
144
|
+
poll(options?: {
|
|
145
|
+
logger?: Logger | undefined;
|
|
146
|
+
}): {
|
|
147
|
+
stop(): void;
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
/** Telegram over a bot the user connects with one tap. Fans out per linked chat; blocked chats are pruned. */
|
|
151
|
+
declare function telegram(options: TelegramOptions): TelegramPlugin;
|
|
152
|
+
|
|
153
|
+
export { type TelegramChat, type TelegramLink, type TelegramOptions, type TelegramPlugin, type TelegramRendered, isButtonUrlAllowed, telegram, telegramSchema };
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { telegramStartLink } from '../chunk-SSHVZHSJ.js';
|
|
2
|
+
import { escapeHtml } from '../chunk-R3BE67L7.js';
|
|
3
|
+
import { readJsonBody } from '../chunk-JOVIZXO6.js';
|
|
4
|
+
import '../chunk-ORZKH224.js';
|
|
5
|
+
import { encodeBase64Url } from '../chunk-PTGHMCCG.js';
|
|
6
|
+
|
|
7
|
+
// src/plugins/telegram/index.ts
|
|
8
|
+
var CHAT = "notification_telegram_chat";
|
|
9
|
+
var LINK = "notification_telegram_link";
|
|
10
|
+
var DEFAULT_MESSAGES = {
|
|
11
|
+
connected: "Connected. You will receive notifications here.",
|
|
12
|
+
disconnected: "Disconnected. You will not receive notifications here any more.",
|
|
13
|
+
expired: "That link has expired. Open the app and connect Telegram again.",
|
|
14
|
+
start: "Open the app and use \u201CConnect Telegram\u201D to link this chat."
|
|
15
|
+
};
|
|
16
|
+
var telegramSchema = {
|
|
17
|
+
telegramChat: {
|
|
18
|
+
tableName: CHAT,
|
|
19
|
+
fields: {
|
|
20
|
+
id: { type: "string", required: true },
|
|
21
|
+
userId: { type: "string", required: true },
|
|
22
|
+
chatId: { type: "string", required: true },
|
|
23
|
+
chatType: { type: "string", required: true },
|
|
24
|
+
username: { type: "string" },
|
|
25
|
+
title: { type: "string" },
|
|
26
|
+
linkedAt: { type: "date", required: true, defaultNow: true },
|
|
27
|
+
lastSeenAt: { type: "date", required: true, defaultNow: true }
|
|
28
|
+
},
|
|
29
|
+
primaryKey: ["id"],
|
|
30
|
+
indexes: [
|
|
31
|
+
{ on: ["userId"], name: "telegram_chat_user_idx" },
|
|
32
|
+
// A chat belongs to one account at a time; re-linking moves it.
|
|
33
|
+
{ on: ["chatId"], unique: true, name: "telegram_chat_chat_idx" }
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
telegramLink: {
|
|
37
|
+
tableName: LINK,
|
|
38
|
+
fields: {
|
|
39
|
+
code: { type: "string", required: true },
|
|
40
|
+
userId: { type: "string", required: true },
|
|
41
|
+
createdAt: { type: "date", required: true, defaultNow: true },
|
|
42
|
+
expiresAt: { type: "date", required: true }
|
|
43
|
+
},
|
|
44
|
+
primaryKey: ["code"],
|
|
45
|
+
indexes: [{ on: ["userId"], name: "telegram_link_user_idx" }]
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
var randomCode = () => encodeBase64Url(crypto.getRandomValues(new Uint8Array(24)));
|
|
49
|
+
function isButtonUrlAllowed(value) {
|
|
50
|
+
let url;
|
|
51
|
+
try {
|
|
52
|
+
url = new URL(value);
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return url.protocol === "tg:";
|
|
57
|
+
const host = url.hostname.toLowerCase();
|
|
58
|
+
return !(host === "localhost" || /\.(localhost|local|internal|lan)$/.test(host) || /^\d+\.\d+\.\d+\.\d+$/.test(host) || host.startsWith("["));
|
|
59
|
+
}
|
|
60
|
+
var encoder = new TextEncoder();
|
|
61
|
+
async function timingSafeEqual(a, b) {
|
|
62
|
+
const [left, right] = await Promise.all(
|
|
63
|
+
[a, b].map(
|
|
64
|
+
async (value) => new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value)))
|
|
65
|
+
)
|
|
66
|
+
);
|
|
67
|
+
let diff = 0;
|
|
68
|
+
for (let i = 0; i < 32; i += 1) diff |= (left?.[i] ?? 0) ^ (right?.[i] ?? 0);
|
|
69
|
+
return diff === 0;
|
|
70
|
+
}
|
|
71
|
+
function telegram(options) {
|
|
72
|
+
let ctx;
|
|
73
|
+
const messages = { ...DEFAULT_MESSAGES, ...options.messages };
|
|
74
|
+
const ttlMs = (options.linkTtlMinutes ?? 10) * 6e4;
|
|
75
|
+
const maxChats = Math.max(1, options.maxChatsPerUser ?? 5);
|
|
76
|
+
const botUsername = options.botUsername.replace(/^@/, "");
|
|
77
|
+
const reply = async (chatId, text) => {
|
|
78
|
+
const result = await options.provider.send({ chatId, text: escapeHtml(text) });
|
|
79
|
+
if (!result.ok) ctx.logger.warn(`telegram reply to chat failed: ${result.error}`);
|
|
80
|
+
};
|
|
81
|
+
async function link(code, chat) {
|
|
82
|
+
const [pending] = await ctx.store.find(LINK, { code }, { limit: 1 });
|
|
83
|
+
const chatId = String(chat.id);
|
|
84
|
+
if (!pending || pending.expiresAt.getTime() < Date.now()) {
|
|
85
|
+
if (pending) await ctx.store.remove(LINK, { code });
|
|
86
|
+
await reply(chatId, messages.expired);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
await ctx.store.remove(LINK, { code });
|
|
90
|
+
const now = /* @__PURE__ */ new Date();
|
|
91
|
+
const details = {
|
|
92
|
+
chatType: chat.type,
|
|
93
|
+
username: chat.username ?? null,
|
|
94
|
+
title: chat.title ?? null,
|
|
95
|
+
lastSeenAt: now
|
|
96
|
+
};
|
|
97
|
+
const [existing] = await ctx.store.find(CHAT, { chatId }, { limit: 1 });
|
|
98
|
+
if (existing) {
|
|
99
|
+
await ctx.store.update(
|
|
100
|
+
CHAT,
|
|
101
|
+
{ id: existing.id },
|
|
102
|
+
{ userId: pending.userId, linkedAt: now, ...details }
|
|
103
|
+
);
|
|
104
|
+
} else {
|
|
105
|
+
const chats = await ctx.store.find(
|
|
106
|
+
CHAT,
|
|
107
|
+
{ userId: pending.userId },
|
|
108
|
+
{ orderBy: { field: "lastSeenAt", direction: "asc" } }
|
|
109
|
+
);
|
|
110
|
+
const excess = chats.length - maxChats + 1;
|
|
111
|
+
if (excess > 0) {
|
|
112
|
+
await ctx.store.remove(CHAT, { id: { in: chats.slice(0, excess).map((row) => row.id) } });
|
|
113
|
+
}
|
|
114
|
+
await ctx.store.insert(CHAT, [
|
|
115
|
+
{ id: crypto.randomUUID(), userId: pending.userId, chatId, linkedAt: now, ...details }
|
|
116
|
+
]);
|
|
117
|
+
}
|
|
118
|
+
await reply(chatId, messages.connected);
|
|
119
|
+
}
|
|
120
|
+
async function handleUpdate(update) {
|
|
121
|
+
const member = update.my_chat_member;
|
|
122
|
+
if (member && ["kicked", "left"].includes(member.new_chat_member.status)) {
|
|
123
|
+
await ctx.store.remove(CHAT, { chatId: String(member.chat.id) });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const message = update.message;
|
|
127
|
+
if (!message?.text) return;
|
|
128
|
+
const chatId = String(message.chat.id);
|
|
129
|
+
const [command, argument] = message.text.trim().split(/\s+/, 2);
|
|
130
|
+
const name = command?.split("@")[0];
|
|
131
|
+
if (name === "/start") {
|
|
132
|
+
if (argument) await link(argument, message.chat);
|
|
133
|
+
else await reply(chatId, messages.start);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (name === "/stop") {
|
|
137
|
+
const removed = await ctx.store.remove(CHAT, { chatId });
|
|
138
|
+
if (removed > 0) await reply(chatId, messages.disconnected);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const routes = [
|
|
142
|
+
{
|
|
143
|
+
path: "/telegram/link",
|
|
144
|
+
method: "POST",
|
|
145
|
+
scope: { type: "user" },
|
|
146
|
+
handler: async ({ userId }) => {
|
|
147
|
+
const owner = userId ?? "";
|
|
148
|
+
const now = Date.now();
|
|
149
|
+
await ctx.store.remove(LINK, { userId: owner, expiresAt: { lt: new Date(now) } });
|
|
150
|
+
const code = randomCode();
|
|
151
|
+
const expiresAt = new Date(now + ttlMs);
|
|
152
|
+
await ctx.store.insert(LINK, [
|
|
153
|
+
{ code, userId: owner, createdAt: new Date(now), expiresAt }
|
|
154
|
+
]);
|
|
155
|
+
return Response.json({
|
|
156
|
+
url: telegramStartLink(botUsername, code),
|
|
157
|
+
code,
|
|
158
|
+
expiresAt: expiresAt.toISOString()
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
path: "/telegram/chats",
|
|
164
|
+
method: "GET",
|
|
165
|
+
scope: { type: "user" },
|
|
166
|
+
handler: async ({ userId }) => {
|
|
167
|
+
const chats = await ctx.store.find(
|
|
168
|
+
CHAT,
|
|
169
|
+
{ userId: userId ?? "" },
|
|
170
|
+
{ orderBy: { field: "linkedAt", direction: "desc" } }
|
|
171
|
+
);
|
|
172
|
+
return Response.json({
|
|
173
|
+
chats: chats.map((chat) => ({
|
|
174
|
+
chatId: chat.chatId,
|
|
175
|
+
chatType: chat.chatType,
|
|
176
|
+
username: chat.username,
|
|
177
|
+
title: chat.title,
|
|
178
|
+
linkedAt: chat.linkedAt.toISOString()
|
|
179
|
+
}))
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
path: "/telegram/unlink",
|
|
185
|
+
method: "POST",
|
|
186
|
+
scope: { type: "user" },
|
|
187
|
+
handler: async ({ request, userId }) => {
|
|
188
|
+
const parsed = await readJsonBody(request);
|
|
189
|
+
if ("error" in parsed) return parsed.error;
|
|
190
|
+
const { chatId } = parsed.body;
|
|
191
|
+
const owner = userId ?? "";
|
|
192
|
+
const removed = await ctx.store.remove(
|
|
193
|
+
CHAT,
|
|
194
|
+
typeof chatId === "string" && chatId !== "" ? { userId: owner, chatId } : { userId: owner }
|
|
195
|
+
);
|
|
196
|
+
return Response.json({ removed });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
];
|
|
200
|
+
if (options.webhookSecret) {
|
|
201
|
+
const secret = options.webhookSecret;
|
|
202
|
+
routes.push({
|
|
203
|
+
path: "/telegram/webhook",
|
|
204
|
+
method: "POST",
|
|
205
|
+
scope: {
|
|
206
|
+
type: "custom",
|
|
207
|
+
justification: "called by Telegram, authenticated by the X-Telegram-Bot-Api-Secret-Token header set through setWebhook"
|
|
208
|
+
},
|
|
209
|
+
handler: async ({ request }) => {
|
|
210
|
+
const presented = request.headers.get("x-telegram-bot-api-secret-token") ?? "";
|
|
211
|
+
if (!await timingSafeEqual(presented, secret)) return new Response(null, { status: 401 });
|
|
212
|
+
const parsed = await readJsonBody(request);
|
|
213
|
+
if ("error" in parsed) return parsed.error;
|
|
214
|
+
try {
|
|
215
|
+
await handleUpdate(parsed.body);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
ctx.logger.error("telegram update failed", { error });
|
|
218
|
+
}
|
|
219
|
+
return Response.json({ ok: true });
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
id: "telegram",
|
|
225
|
+
channels: ["telegram"],
|
|
226
|
+
schema: telegramSchema,
|
|
227
|
+
init: (context) => {
|
|
228
|
+
ctx = context;
|
|
229
|
+
},
|
|
230
|
+
hooks: {
|
|
231
|
+
deliver: async (context) => {
|
|
232
|
+
const chats = await ctx.store.find(CHAT, {
|
|
233
|
+
userId: context.notification.userId
|
|
234
|
+
});
|
|
235
|
+
if (chats.length === 0) return { result: "skipped", reason: "no linked telegram chat" };
|
|
236
|
+
const rendered = options.render({
|
|
237
|
+
type: context.notification.type,
|
|
238
|
+
payload: context.notification.payload
|
|
239
|
+
});
|
|
240
|
+
let button = rendered.button;
|
|
241
|
+
if (button && !isButtonUrlAllowed(button.url)) {
|
|
242
|
+
ctx.logger.warn(
|
|
243
|
+
`telegram: dropped button "${button.url}"; Telegram accepts only public http(s) links`
|
|
244
|
+
);
|
|
245
|
+
button = void 0;
|
|
246
|
+
}
|
|
247
|
+
const results = await Promise.all(
|
|
248
|
+
chats.map(
|
|
249
|
+
(chat) => options.provider.send({
|
|
250
|
+
chatId: chat.chatId,
|
|
251
|
+
text: rendered.text,
|
|
252
|
+
buttons: button ? [button] : void 0,
|
|
253
|
+
signal: context.signal
|
|
254
|
+
})
|
|
255
|
+
)
|
|
256
|
+
);
|
|
257
|
+
const prune = [];
|
|
258
|
+
let delivered = 0;
|
|
259
|
+
let retryable = false;
|
|
260
|
+
let lastError = "";
|
|
261
|
+
results.forEach((result, index) => {
|
|
262
|
+
const chat = chats[index];
|
|
263
|
+
if (!chat) return;
|
|
264
|
+
if (result.ok) {
|
|
265
|
+
delivered += 1;
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
lastError = result.error;
|
|
269
|
+
if (result.gone) prune.push(chat.id);
|
|
270
|
+
else if (result.retryable) retryable = true;
|
|
271
|
+
});
|
|
272
|
+
if (prune.length > 0) await ctx.store.remove(CHAT, { id: { in: prune } });
|
|
273
|
+
if (delivered > 0) return { result: "sent" };
|
|
274
|
+
if (prune.length === chats.length) {
|
|
275
|
+
return { result: "failed", error: "every linked chat is gone", retryable: false };
|
|
276
|
+
}
|
|
277
|
+
return { result: "failed", error: lastError || "telegram refused the message", retryable };
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
routes,
|
|
281
|
+
handleUpdate,
|
|
282
|
+
poll(pollOptions = {}) {
|
|
283
|
+
const logger = pollOptions.logger ?? ctx.logger;
|
|
284
|
+
const controller = new AbortController();
|
|
285
|
+
let offset;
|
|
286
|
+
void (async () => {
|
|
287
|
+
while (!controller.signal.aborted) {
|
|
288
|
+
try {
|
|
289
|
+
const updates = await options.provider.getUpdates({
|
|
290
|
+
offset,
|
|
291
|
+
signal: controller.signal
|
|
292
|
+
});
|
|
293
|
+
for (const update of updates) {
|
|
294
|
+
offset = update.update_id + 1;
|
|
295
|
+
await handleUpdate(update).catch(
|
|
296
|
+
(error) => logger.error("telegram update failed", { error })
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (controller.signal.aborted) return;
|
|
301
|
+
logger.error("telegram polling failed", { error });
|
|
302
|
+
await new Promise((resolve) => setTimeout(resolve, 3e3));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
})();
|
|
306
|
+
return { stop: () => controller.abort() };
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export { isButtonUrlAllowed, telegram, telegramSchema };
|
|
312
|
+
//# sourceMappingURL=telegram.js.map
|
|
313
|
+
//# sourceMappingURL=telegram.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/plugins/telegram/index.ts"],"names":[],"mappings":"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","file":"telegram.js","sourcesContent":["import type { DeliveryOutcome } from \"../../core/adapter\";\nimport { encodeBase64Url } from \"../../core/base64url\";\nimport type { Logger } from \"../../core/errors\";\nimport { readJsonBody } from \"../../core/handler\";\nimport { escapeHtml } from \"../../core/html\";\nimport type {\n DeliverContext,\n EasyPingPlugin,\n PluginInitContext,\n RouteDefinition,\n SchemaDeclaration,\n} from \"../../core/plugin\";\nimport {\n type TelegramProvider,\n type TelegramUpdate,\n telegramStartLink,\n} from \"../../providers/telegram\";\n\nconst CHAT = \"notification_telegram_chat\";\nconst LINK = \"notification_telegram_link\";\n\nexport type TelegramChat = {\n id: string;\n userId: string;\n chatId: string;\n /** \"private\", \"group\", \"supergroup\" or \"channel\". */\n chatType: string;\n username: string | null;\n title: string | null;\n linkedAt: Date;\n lastSeenAt: Date;\n};\n\nexport type TelegramLink = {\n code: string;\n userId: string;\n createdAt: Date;\n expiresAt: Date;\n};\n\nexport type TelegramRendered = {\n /** Telegram HTML. Anything user-typed inside it goes through escapeHtml(). */\n text: string;\n /** Adds one inline button under the message. */\n button?: { text: string; url: string } | undefined;\n};\n\nexport type TelegramOptions = {\n provider: TelegramProvider;\n /** Without the @. Builds the https://t.me/<bot>?start=<code> link. */\n botUsername: string;\n render: (input: { type: string; payload: unknown }) => TelegramRendered;\n /**\n * Shared with Telegram through setWebhook; Telegram echoes it in\n * X-Telegram-Bot-Api-Secret-Token and the webhook route accepts nothing else.\n * Unset, the route is not mounted and updates must come through poll().\n */\n webhookSecret?: string | undefined;\n /** How long a \"Connect Telegram\" link stays valid. Default 10 minutes. */\n linkTtlMinutes?: number | undefined;\n /** Linking past this evicts the user's least recently seen chat. Default 5. */\n maxChatsPerUser?: number | undefined;\n /** Bot replies, if the defaults are not your voice. */\n messages?: Partial<typeof DEFAULT_MESSAGES> | undefined;\n};\n\nconst DEFAULT_MESSAGES = {\n connected: \"Connected. You will receive notifications here.\",\n disconnected: \"Disconnected. You will not receive notifications here any more.\",\n expired: \"That link has expired. Open the app and connect Telegram again.\",\n start: \"Open the app and use “Connect Telegram” to link this chat.\",\n};\n\n/** The tables this plugin owns, exported so DDL needs no plugin instance. */\nexport const telegramSchema = {\n telegramChat: {\n tableName: CHAT,\n fields: {\n id: { type: \"string\", required: true },\n userId: { type: \"string\", required: true },\n chatId: { type: \"string\", required: true },\n chatType: { type: \"string\", required: true },\n username: { type: \"string\" },\n title: { type: \"string\" },\n linkedAt: { type: \"date\", required: true, defaultNow: true },\n lastSeenAt: { type: \"date\", required: true, defaultNow: true },\n },\n primaryKey: [\"id\"],\n indexes: [\n { on: [\"userId\"], name: \"telegram_chat_user_idx\" },\n // A chat belongs to one account at a time; re-linking moves it.\n { on: [\"chatId\"], unique: true, name: \"telegram_chat_chat_idx\" },\n ],\n },\n telegramLink: {\n tableName: LINK,\n fields: {\n code: { type: \"string\", required: true },\n userId: { type: \"string\", required: true },\n createdAt: { type: \"date\", required: true, defaultNow: true },\n expiresAt: { type: \"date\", required: true },\n },\n primaryKey: [\"code\"],\n indexes: [{ on: [\"userId\"], name: \"telegram_link_user_idx\" }],\n },\n} as const satisfies SchemaDeclaration;\n\nconst randomCode = () => encodeBase64Url(crypto.getRandomValues(new Uint8Array(24)));\n\n/** Telegram refuses buttons to localhost, private hosts and non-http schemes with a 400 for the whole message. */\nexport function isButtonUrlAllowed(value: string): boolean {\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return false;\n }\n if (url.protocol !== \"https:\" && url.protocol !== \"http:\") return url.protocol === \"tg:\";\n const host = url.hostname.toLowerCase();\n return !(\n host === \"localhost\" ||\n /\\.(localhost|local|internal|lan)$/.test(host) ||\n /^\\d+\\.\\d+\\.\\d+\\.\\d+$/.test(host) ||\n host.startsWith(\"[\")\n );\n}\n\nconst encoder = new TextEncoder();\n\nasync function timingSafeEqual(a: string, b: string): Promise<boolean> {\n const [left, right] = await Promise.all(\n [a, b].map(\n async (value) => new Uint8Array(await crypto.subtle.digest(\"SHA-256\", encoder.encode(value))),\n ),\n );\n let diff = 0;\n for (let i = 0; i < 32; i += 1) diff |= (left?.[i] ?? 0) ^ (right?.[i] ?? 0);\n return diff === 0;\n}\n\nexport type TelegramPlugin = EasyPingPlugin<\"telegram\", typeof telegramSchema> & {\n /** Feeds one Bot API update through the linking logic. The webhook route and poll() both call it. */\n handleUpdate(update: TelegramUpdate): Promise<void>;\n /**\n * Long-polls getUpdates instead of a webhook: for development, or a host\n * with no public URL. One poller per bot token, or Telegram splits updates\n * between them. Call stop() on shutdown.\n */\n poll(options?: { logger?: Logger | undefined }): { stop(): void };\n};\n\n/** Telegram over a bot the user connects with one tap. Fans out per linked chat; blocked chats are pruned. */\nexport function telegram(options: TelegramOptions): TelegramPlugin {\n let ctx: PluginInitContext;\n const messages = { ...DEFAULT_MESSAGES, ...options.messages };\n const ttlMs = (options.linkTtlMinutes ?? 10) * 60_000;\n const maxChats = Math.max(1, options.maxChatsPerUser ?? 5);\n const botUsername = options.botUsername.replace(/^@/, \"\");\n\n const reply = async (chatId: string, text: string) => {\n const result = await options.provider.send({ chatId, text: escapeHtml(text) });\n if (!result.ok) ctx.logger.warn(`telegram reply to chat failed: ${result.error}`);\n };\n\n async function link(code: string, chat: NonNullable<TelegramUpdate[\"message\"]>[\"chat\"]) {\n const [pending] = await ctx.store.find<TelegramLink>(LINK, { code }, { limit: 1 });\n const chatId = String(chat.id);\n\n if (!pending || pending.expiresAt.getTime() < Date.now()) {\n if (pending) await ctx.store.remove(LINK, { code });\n await reply(chatId, messages.expired);\n return;\n }\n // One-time: the code is spent whether or not the rest succeeds.\n await ctx.store.remove(LINK, { code });\n\n const now = new Date();\n const details = {\n chatType: chat.type,\n username: chat.username ?? null,\n title: chat.title ?? null,\n lastSeenAt: now,\n };\n const [existing] = await ctx.store.find<TelegramChat>(CHAT, { chatId }, { limit: 1 });\n\n if (existing) {\n // Re-linking moves the chat to whoever holds the fresh code: the Telegram\n // user tapped it, and the app session that minted it is theirs.\n await ctx.store.update(\n CHAT,\n { id: existing.id },\n { userId: pending.userId, linkedAt: now, ...details },\n );\n } else {\n const chats = await ctx.store.find<TelegramChat>(\n CHAT,\n { userId: pending.userId },\n { orderBy: { field: \"lastSeenAt\", direction: \"asc\" } },\n );\n const excess = chats.length - maxChats + 1;\n if (excess > 0) {\n await ctx.store.remove(CHAT, { id: { in: chats.slice(0, excess).map((row) => row.id) } });\n }\n await ctx.store.insert(CHAT, [\n { id: crypto.randomUUID(), userId: pending.userId, chatId, linkedAt: now, ...details },\n ]);\n }\n await reply(chatId, messages.connected);\n }\n\n async function handleUpdate(update: TelegramUpdate): Promise<void> {\n const member = update.my_chat_member;\n if (member && [\"kicked\", \"left\"].includes(member.new_chat_member.status)) {\n await ctx.store.remove(CHAT, { chatId: String(member.chat.id) });\n return;\n }\n\n const message = update.message;\n if (!message?.text) return;\n const chatId = String(message.chat.id);\n const [command, argument] = message.text.trim().split(/\\s+/, 2);\n // \"/start@botname\" arrives in groups.\n const name = command?.split(\"@\")[0];\n\n if (name === \"/start\") {\n if (argument) await link(argument, message.chat);\n else await reply(chatId, messages.start);\n return;\n }\n if (name === \"/stop\") {\n const removed = await ctx.store.remove(CHAT, { chatId });\n if (removed > 0) await reply(chatId, messages.disconnected);\n }\n }\n\n const routes: RouteDefinition[] = [\n {\n path: \"/telegram/link\",\n method: \"POST\",\n scope: { type: \"user\" },\n handler: async ({ userId }) => {\n const owner = userId ?? \"\";\n const now = Date.now();\n // A user's stale codes go, so the table cannot fill with abandoned links.\n await ctx.store.remove(LINK, { userId: owner, expiresAt: { lt: new Date(now) } });\n\n const code = randomCode();\n const expiresAt = new Date(now + ttlMs);\n await ctx.store.insert(LINK, [\n { code, userId: owner, createdAt: new Date(now), expiresAt },\n ]);\n return Response.json({\n url: telegramStartLink(botUsername, code),\n code,\n expiresAt: expiresAt.toISOString(),\n });\n },\n },\n {\n path: \"/telegram/chats\",\n method: \"GET\",\n scope: { type: \"user\" },\n handler: async ({ userId }) => {\n const chats = await ctx.store.find<TelegramChat>(\n CHAT,\n { userId: userId ?? \"\" },\n { orderBy: { field: \"linkedAt\", direction: \"desc\" } },\n );\n return Response.json({\n chats: chats.map((chat) => ({\n chatId: chat.chatId,\n chatType: chat.chatType,\n username: chat.username,\n title: chat.title,\n linkedAt: chat.linkedAt.toISOString(),\n })),\n });\n },\n },\n {\n path: \"/telegram/unlink\",\n method: \"POST\",\n scope: { type: \"user\" },\n handler: async ({ request, userId }) => {\n const parsed = await readJsonBody(request);\n if (\"error\" in parsed) return parsed.error;\n const { chatId } = parsed.body;\n const owner = userId ?? \"\";\n // Scoped by userId too: a chat id is not a secret.\n const removed = await ctx.store.remove(\n CHAT,\n typeof chatId === \"string\" && chatId !== \"\"\n ? { userId: owner, chatId }\n : { userId: owner },\n );\n return Response.json({ removed });\n },\n },\n ];\n\n if (options.webhookSecret) {\n const secret = options.webhookSecret;\n routes.push({\n path: \"/telegram/webhook\",\n method: \"POST\",\n scope: {\n type: \"custom\",\n justification:\n \"called by Telegram, authenticated by the X-Telegram-Bot-Api-Secret-Token header set through setWebhook\",\n },\n handler: async ({ request }) => {\n const presented = request.headers.get(\"x-telegram-bot-api-secret-token\") ?? \"\";\n if (!(await timingSafeEqual(presented, secret))) return new Response(null, { status: 401 });\n\n const parsed = await readJsonBody(request);\n if (\"error\" in parsed) return parsed.error;\n try {\n await handleUpdate(parsed.body as unknown as TelegramUpdate);\n } catch (error) {\n // Telegram retries non-2xx for hours; a bad update is logged and acknowledged.\n ctx.logger.error(\"telegram update failed\", { error });\n }\n return Response.json({ ok: true });\n },\n });\n }\n\n return {\n id: \"telegram\",\n channels: [\"telegram\"],\n schema: telegramSchema,\n\n init: (context) => {\n ctx = context;\n },\n\n hooks: {\n deliver: async (context: DeliverContext): Promise<DeliveryOutcome> => {\n const chats = await ctx.store.find<TelegramChat>(CHAT, {\n userId: context.notification.userId,\n });\n if (chats.length === 0) return { result: \"skipped\", reason: \"no linked telegram chat\" };\n\n const rendered = options.render({\n type: context.notification.type,\n payload: context.notification.payload,\n });\n let button = rendered.button;\n if (button && !isButtonUrlAllowed(button.url)) {\n ctx.logger.warn(\n `telegram: dropped button \"${button.url}\"; Telegram accepts only public http(s) links`,\n );\n button = undefined;\n }\n\n const results = await Promise.all(\n chats.map((chat) =>\n options.provider.send({\n chatId: chat.chatId,\n text: rendered.text,\n buttons: button ? [button] : undefined,\n signal: context.signal,\n }),\n ),\n );\n\n const prune: string[] = [];\n let delivered = 0;\n let retryable = false;\n let lastError = \"\";\n results.forEach((result, index) => {\n const chat = chats[index];\n if (!chat) return;\n if (result.ok) {\n delivered += 1;\n return;\n }\n lastError = result.error;\n if (result.gone) prune.push(chat.id);\n else if (result.retryable) retryable = true;\n });\n\n if (prune.length > 0) await ctx.store.remove(CHAT, { id: { in: prune } });\n if (delivered > 0) return { result: \"sent\" };\n if (prune.length === chats.length) {\n return { result: \"failed\", error: \"every linked chat is gone\", retryable: false };\n }\n return { result: \"failed\", error: lastError || \"telegram refused the message\", retryable };\n },\n },\n\n routes,\n handleUpdate,\n\n poll(pollOptions = {}) {\n const logger = pollOptions.logger ?? ctx.logger;\n const controller = new AbortController();\n let offset: number | undefined;\n\n void (async () => {\n while (!controller.signal.aborted) {\n try {\n const updates = await options.provider.getUpdates({\n offset,\n signal: controller.signal,\n });\n for (const update of updates) {\n offset = update.update_id + 1;\n await handleUpdate(update).catch((error) =>\n logger.error(\"telegram update failed\", { error }),\n );\n }\n } catch (error) {\n if (controller.signal.aborted) return;\n logger.error(\"telegram polling failed\", { error });\n await new Promise((resolve) => setTimeout(resolve, 3_000));\n }\n }\n })();\n\n return { stop: () => controller.abort() };\n },\n };\n}\n"]}
|