@strav/instant 1.0.0-alpha.42
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 +49 -0
- package/src/drivers/line/index.ts +50 -0
- package/src/drivers/line/liff_client.ts +126 -0
- package/src/drivers/line/liff_sign_in_route.ts +112 -0
- package/src/drivers/line/line_beacon.ts +14 -0
- package/src/drivers/line/line_config.ts +55 -0
- package/src/drivers/line/line_driver.ts +202 -0
- package/src/drivers/line/line_flex.ts +117 -0
- package/src/drivers/line/line_liff.ts +102 -0
- package/src/drivers/line/line_message_mapper.ts +118 -0
- package/src/drivers/line/line_provider.ts +33 -0
- package/src/drivers/line/line_rich_menu.ts +68 -0
- package/src/drivers/line/line_webhook.ts +168 -0
- package/src/drivers/messenger/index.ts +16 -0
- package/src/drivers/messenger/messenger_config.ts +20 -0
- package/src/drivers/messenger/messenger_driver.ts +151 -0
- package/src/drivers/messenger/messenger_message_mapper.ts +146 -0
- package/src/drivers/messenger/messenger_profile.ts +43 -0
- package/src/drivers/messenger/messenger_provider.ts +31 -0
- package/src/drivers/messenger/messenger_webhook.ts +165 -0
- package/src/drivers/telegram/index.ts +23 -0
- package/src/drivers/telegram/telegram_config.ts +19 -0
- package/src/drivers/telegram/telegram_driver.ts +121 -0
- package/src/drivers/telegram/telegram_message_mapper.ts +147 -0
- package/src/drivers/telegram/telegram_provider.ts +32 -0
- package/src/drivers/telegram/telegram_web_app.ts +108 -0
- package/src/drivers/telegram/telegram_webhook.ts +200 -0
- package/src/drivers/whatsapp/flows/index.ts +15 -0
- package/src/drivers/whatsapp/flows/whatsapp_flow_builder.ts +55 -0
- package/src/drivers/whatsapp/flows/whatsapp_flow_crypto.ts +81 -0
- package/src/drivers/whatsapp/index.ts +14 -0
- package/src/drivers/whatsapp/whatsapp_config.ts +35 -0
- package/src/drivers/whatsapp/whatsapp_driver.ts +111 -0
- package/src/drivers/whatsapp/whatsapp_message_mapper.ts +157 -0
- package/src/drivers/whatsapp/whatsapp_provider.ts +31 -0
- package/src/drivers/whatsapp/whatsapp_webhook.ts +170 -0
- package/src/drivers/zalo/index.ts +16 -0
- package/src/drivers/zalo/zalo_config.ts +37 -0
- package/src/drivers/zalo/zalo_driver.ts +130 -0
- package/src/drivers/zalo/zalo_message_mapper.ts +146 -0
- package/src/drivers/zalo/zalo_mini_app.ts +17 -0
- package/src/drivers/zalo/zalo_provider.ts +31 -0
- package/src/drivers/zalo/zalo_webhook.ts +139 -0
- package/src/errors.ts +122 -0
- package/src/index.ts +52 -0
- package/src/instant_capabilities.ts +49 -0
- package/src/instant_driver.ts +109 -0
- package/src/instant_manager.ts +113 -0
- package/src/instant_provider.ts +45 -0
- package/src/internal/fetch_json.ts +58 -0
- package/src/internal/meta/meta_graph_client.ts +51 -0
- package/src/internal/meta/meta_signature.ts +22 -0
- package/src/internal/meta/meta_webhook_challenge.ts +19 -0
- package/src/message.ts +65 -0
- package/src/types.ts +32 -0
- package/src/webhook_event.ts +93 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `TelegramDriver` — `InstantDriver` for the Telegram Bot API.
|
|
3
|
+
*
|
|
4
|
+
* One bot token, one driver instance. All vendor calls go
|
|
5
|
+
* through `fetchJson` against `https://api.telegram.org/bot<token>`;
|
|
6
|
+
* no SDK dependency.
|
|
7
|
+
*
|
|
8
|
+
* Telegram has no `reply` endpoint — replies are normal sends
|
|
9
|
+
* with `reply_parameters.message_id` set. No `multicast` or
|
|
10
|
+
* `broadcast` either: those are forbidden by Bot API policy
|
|
11
|
+
* (bots can only message users that started a chat with them).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { InstantProviderError } from '../../errors.ts'
|
|
15
|
+
import type { InstantCapability } from '../../instant_capabilities.ts'
|
|
16
|
+
import type { InstantDriver, UserProfile, WebhookOps } from '../../instant_driver.ts'
|
|
17
|
+
import type { OutgoingMessage, SendResult } from '../../message.ts'
|
|
18
|
+
import { fetchJson } from '../../internal/fetch_json.ts'
|
|
19
|
+
import type { WebhookEvent } from '../../webhook_event.ts'
|
|
20
|
+
import type { TelegramProviderConfig } from './telegram_config.ts'
|
|
21
|
+
import { toTelegramCalls } from './telegram_message_mapper.ts'
|
|
22
|
+
import { parseTelegramWebhook, verifyTelegramSignature } from './telegram_webhook.ts'
|
|
23
|
+
|
|
24
|
+
const DEFAULT_CAPABILITIES: ReadonlySet<InstantCapability> = new Set<InstantCapability>([
|
|
25
|
+
'send.text',
|
|
26
|
+
'send.image',
|
|
27
|
+
'send.video',
|
|
28
|
+
'send.audio',
|
|
29
|
+
'send.file',
|
|
30
|
+
'send.location',
|
|
31
|
+
'send.sticker',
|
|
32
|
+
'send.quickReplies',
|
|
33
|
+
'send.interactive',
|
|
34
|
+
'push',
|
|
35
|
+
'profile',
|
|
36
|
+
'miniApp',
|
|
37
|
+
'persistentMenu',
|
|
38
|
+
'webhook.signature',
|
|
39
|
+
'webhook.parse',
|
|
40
|
+
])
|
|
41
|
+
|
|
42
|
+
export interface TelegramDriverOptions {
|
|
43
|
+
instanceName: string
|
|
44
|
+
config: TelegramProviderConfig
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class TelegramDriver implements InstantDriver {
|
|
48
|
+
readonly name = 'telegram'
|
|
49
|
+
readonly instanceName: string
|
|
50
|
+
readonly capabilities = DEFAULT_CAPABILITIES
|
|
51
|
+
readonly webhook: WebhookOps
|
|
52
|
+
private readonly base: string
|
|
53
|
+
|
|
54
|
+
constructor(options: TelegramDriverOptions) {
|
|
55
|
+
const { instanceName, config } = options
|
|
56
|
+
if (!config.botToken) {
|
|
57
|
+
throw new InstantProviderError(
|
|
58
|
+
`TelegramDriver: \`botToken\` is required for provider "${instanceName}".`,
|
|
59
|
+
{ provider: 'telegram', operation: 'init', status: 500 },
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
this.instanceName = instanceName
|
|
63
|
+
const baseURL = config.apiBaseURL ?? 'https://api.telegram.org'
|
|
64
|
+
this.base = `${baseURL}/bot${config.botToken}`
|
|
65
|
+
const secret = config.webhookSecretToken
|
|
66
|
+
this.webhook = {
|
|
67
|
+
verifySignature: (_rawBody, header) => verifyTelegramSignature(header, secret),
|
|
68
|
+
parse: (rawBody): WebhookEvent[] => parseTelegramWebhook(rawBody),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async send(to: string, message: OutgoingMessage): Promise<SendResult> {
|
|
73
|
+
return this.push(to, message)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async push(to: string, message: OutgoingMessage): Promise<SendResult> {
|
|
77
|
+
const calls = toTelegramCalls(to, message)
|
|
78
|
+
const results: unknown[] = []
|
|
79
|
+
let lastMessageId: string | undefined
|
|
80
|
+
for (const call of calls) {
|
|
81
|
+
const response = await this.invoke<{ result?: { message_id?: number } }>(call.method, call.params)
|
|
82
|
+
results.push(response)
|
|
83
|
+
if (response.result?.message_id !== undefined) lastMessageId = String(response.result.message_id)
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
provider: 'telegram',
|
|
87
|
+
accepted: true,
|
|
88
|
+
...(lastMessageId ? { messageId: lastMessageId } : {}),
|
|
89
|
+
raw: results,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async profile(userId: string): Promise<UserProfile> {
|
|
94
|
+
const response = await this.invoke<{
|
|
95
|
+
result: {
|
|
96
|
+
id: number
|
|
97
|
+
first_name?: string
|
|
98
|
+
last_name?: string
|
|
99
|
+
username?: string
|
|
100
|
+
photo?: { small_file_id: string }
|
|
101
|
+
}
|
|
102
|
+
}>('getChat', { chat_id: userId })
|
|
103
|
+
const r = response.result
|
|
104
|
+
const displayName = [r.first_name, r.last_name].filter(Boolean).join(' ') || r.username
|
|
105
|
+
return {
|
|
106
|
+
userId: String(r.id),
|
|
107
|
+
...(displayName ? { displayName } : {}),
|
|
108
|
+
raw: r,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private async invoke<T>(method: string, params: Record<string, unknown>): Promise<T> {
|
|
113
|
+
return fetchJson<T>(`${this.base}/${method}`, {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: { 'Content-Type': 'application/json' },
|
|
116
|
+
body: JSON.stringify(params),
|
|
117
|
+
provider: 'telegram',
|
|
118
|
+
operation: method,
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map `OutgoingMessage` → Telegram Bot-API method + params.
|
|
3
|
+
*
|
|
4
|
+
* Telegram has one method per content type (sendMessage,
|
|
5
|
+
* sendPhoto, sendVideo, …), so the mapper returns a list of
|
|
6
|
+
* (method, params) tuples — a text + image becomes two calls.
|
|
7
|
+
* Multiple media attachments collapse into a single
|
|
8
|
+
* `sendMediaGroup` call.
|
|
9
|
+
*
|
|
10
|
+
* Provider-native escape hatches via `raw`:
|
|
11
|
+
* - `raw.inlineKeyboard?: InlineKeyboardButton[][]` → `reply_markup.inline_keyboard`
|
|
12
|
+
* - `raw.replyToMessageId?: number` → `reply_parameters.message_id`
|
|
13
|
+
* - `raw.parseMode?: 'MarkdownV2' | 'HTML'` → `parse_mode`
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { InstantProviderError } from '../../errors.ts'
|
|
17
|
+
import type { Attachment, OutgoingMessage, QuickReply } from '../../message.ts'
|
|
18
|
+
|
|
19
|
+
export interface TelegramCall {
|
|
20
|
+
method: string
|
|
21
|
+
params: Record<string, unknown>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface TelegramRawExtras {
|
|
25
|
+
inlineKeyboard?: unknown[][]
|
|
26
|
+
replyToMessageId?: number
|
|
27
|
+
parseMode?: 'MarkdownV2' | 'HTML'
|
|
28
|
+
disableNotification?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function toTelegramCalls(chatId: string, message: OutgoingMessage): TelegramCall[] {
|
|
32
|
+
const calls: TelegramCall[] = []
|
|
33
|
+
const raw = (message.raw ?? {}) as TelegramRawExtras
|
|
34
|
+
const base = baseFields(chatId, raw)
|
|
35
|
+
const replyMarkup = buildReplyMarkup(message.quickReplies, raw.inlineKeyboard)
|
|
36
|
+
|
|
37
|
+
const attachments = message.attachments ?? []
|
|
38
|
+
|
|
39
|
+
// Multi-media (≥2 media items) → one sendMediaGroup
|
|
40
|
+
const mediaGroup = attachments.filter(
|
|
41
|
+
(a): a is Extract<Attachment, { type: 'image' | 'video' }> =>
|
|
42
|
+
a.type === 'image' || a.type === 'video',
|
|
43
|
+
)
|
|
44
|
+
if (mediaGroup.length >= 2) {
|
|
45
|
+
const media = mediaGroup.map((a, index) => ({
|
|
46
|
+
type: a.type === 'image' ? 'photo' : 'video',
|
|
47
|
+
media: a.url,
|
|
48
|
+
...(index === 0 && message.text ? { caption: message.text } : {}),
|
|
49
|
+
}))
|
|
50
|
+
calls.push({ method: 'sendMediaGroup', params: { ...base, media } })
|
|
51
|
+
const leftovers = attachments.filter(
|
|
52
|
+
(a) => !(mediaGroup as readonly Attachment[]).includes(a),
|
|
53
|
+
)
|
|
54
|
+
for (const a of leftovers) calls.push(attachmentCall(a, base))
|
|
55
|
+
return calls
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (attachments.length === 0) {
|
|
59
|
+
if (!message.text) {
|
|
60
|
+
throw new InstantProviderError(
|
|
61
|
+
'Telegram send requires either `text` or at least one attachment.',
|
|
62
|
+
{ provider: 'telegram', operation: 'send', status: 400 },
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
calls.push({
|
|
66
|
+
method: 'sendMessage',
|
|
67
|
+
params: { ...base, text: message.text, ...(replyMarkup ? { reply_markup: replyMarkup } : {}) },
|
|
68
|
+
})
|
|
69
|
+
return calls
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Single attachment: caption-eligible types absorb `text`.
|
|
73
|
+
const [head, ...rest] = attachments
|
|
74
|
+
if (head) {
|
|
75
|
+
const captionEligible =
|
|
76
|
+
head.type === 'image' || head.type === 'video' || head.type === 'audio' || head.type === 'file'
|
|
77
|
+
const headCall = attachmentCall(head, base)
|
|
78
|
+
if (captionEligible && message.text) headCall.params['caption'] = message.text
|
|
79
|
+
if (replyMarkup) headCall.params['reply_markup'] = replyMarkup
|
|
80
|
+
calls.push(headCall)
|
|
81
|
+
if (!captionEligible && message.text) {
|
|
82
|
+
calls.push({ method: 'sendMessage', params: { ...base, text: message.text } })
|
|
83
|
+
}
|
|
84
|
+
for (const a of rest) calls.push(attachmentCall(a, base))
|
|
85
|
+
}
|
|
86
|
+
return calls
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function baseFields(chatId: string, raw: TelegramRawExtras): Record<string, unknown> {
|
|
90
|
+
return {
|
|
91
|
+
chat_id: chatId,
|
|
92
|
+
...(raw.parseMode ? { parse_mode: raw.parseMode } : {}),
|
|
93
|
+
...(raw.disableNotification ? { disable_notification: true } : {}),
|
|
94
|
+
...(raw.replyToMessageId
|
|
95
|
+
? { reply_parameters: { message_id: raw.replyToMessageId } }
|
|
96
|
+
: {}),
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function attachmentCall(a: Attachment, base: Record<string, unknown>): TelegramCall {
|
|
101
|
+
switch (a.type) {
|
|
102
|
+
case 'image':
|
|
103
|
+
return { method: 'sendPhoto', params: { ...base, photo: a.url } }
|
|
104
|
+
case 'video':
|
|
105
|
+
return { method: 'sendVideo', params: { ...base, video: a.url } }
|
|
106
|
+
case 'audio':
|
|
107
|
+
return { method: 'sendAudio', params: { ...base, audio: a.url } }
|
|
108
|
+
case 'file':
|
|
109
|
+
return { method: 'sendDocument', params: { ...base, document: a.url } }
|
|
110
|
+
case 'location':
|
|
111
|
+
return {
|
|
112
|
+
method: 'sendLocation',
|
|
113
|
+
params: { ...base, latitude: a.latitude, longitude: a.longitude },
|
|
114
|
+
}
|
|
115
|
+
case 'sticker':
|
|
116
|
+
// Telegram stickers are addressed by file_id; we forward stickerId verbatim.
|
|
117
|
+
return { method: 'sendSticker', params: { ...base, sticker: a.stickerId } }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function buildReplyMarkup(
|
|
122
|
+
quickReplies: QuickReply[] | undefined,
|
|
123
|
+
inlineKeyboard: unknown[][] | undefined,
|
|
124
|
+
): Record<string, unknown> | undefined {
|
|
125
|
+
if (inlineKeyboard) return { inline_keyboard: inlineKeyboard }
|
|
126
|
+
if (!quickReplies || quickReplies.length === 0) return undefined
|
|
127
|
+
// Map QuickReply[] → inline_keyboard with one button per row.
|
|
128
|
+
// `postback` → callback_data; `uri` → url; `message` falls back to text.
|
|
129
|
+
const inline = quickReplies.map((qr) => {
|
|
130
|
+
const button: Record<string, unknown> = { text: qr.label }
|
|
131
|
+
switch (qr.action.type) {
|
|
132
|
+
case 'postback':
|
|
133
|
+
button['callback_data'] = qr.action.data
|
|
134
|
+
break
|
|
135
|
+
case 'uri':
|
|
136
|
+
button['url'] = qr.action.uri
|
|
137
|
+
break
|
|
138
|
+
case 'message':
|
|
139
|
+
// No native equivalent — emit as callback_data so the bot
|
|
140
|
+
// can still respond. App may override via raw.inlineKeyboard.
|
|
141
|
+
button['callback_data'] = qr.action.text
|
|
142
|
+
break
|
|
143
|
+
}
|
|
144
|
+
return [button]
|
|
145
|
+
})
|
|
146
|
+
return { inline_keyboard: inline }
|
|
147
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `TelegramInstantProvider` — `ServiceProvider` that registers
|
|
3
|
+
* the Telegram driver factory on the `InstantManager`.
|
|
4
|
+
*
|
|
5
|
+
* Apps list this AFTER `InstantProvider`. Drivers construct
|
|
6
|
+
* lazily on first `instant.use(name)` call.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { type Application, ServiceProvider } from '@strav/kernel'
|
|
10
|
+
import { InstantConfigError } from '../../errors.ts'
|
|
11
|
+
import { InstantManager } from '../../instant_manager.ts'
|
|
12
|
+
import type { TelegramProviderConfig } from './telegram_config.ts'
|
|
13
|
+
import { TelegramDriver } from './telegram_driver.ts'
|
|
14
|
+
|
|
15
|
+
export class TelegramInstantProvider extends ServiceProvider {
|
|
16
|
+
override readonly name = 'instant-telegram'
|
|
17
|
+
override readonly dependencies = ['instant']
|
|
18
|
+
|
|
19
|
+
override register(app: Application): void {
|
|
20
|
+
const manager = app.resolve(InstantManager)
|
|
21
|
+
manager.extend('telegram', ({ instanceName, config }) => {
|
|
22
|
+
const cfg = config as TelegramProviderConfig
|
|
23
|
+
if (!cfg.botToken) {
|
|
24
|
+
throw new InstantConfigError(
|
|
25
|
+
`TelegramInstantProvider: \`botToken\` is required for provider "${instanceName}".`,
|
|
26
|
+
{ context: { instanceName } },
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
return new TelegramDriver({ instanceName, config: cfg })
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram Web App helpers — the LIFF analogue for Telegram.
|
|
3
|
+
*
|
|
4
|
+
* `verifyTelegramWebAppInitData(initData, botToken)` validates
|
|
5
|
+
* the `initData` query string a Web App passes from the client
|
|
6
|
+
* SDK. The signature scheme:
|
|
7
|
+
*
|
|
8
|
+
* secret_key = HMAC_SHA256(bot_token, key="WebAppData")
|
|
9
|
+
* expected = HMAC_SHA256(data_check_string, key=secret_key) (hex)
|
|
10
|
+
*
|
|
11
|
+
* `data_check_string` is `key=value` pairs (sorted by key, joined
|
|
12
|
+
* by `\n`) of every initData field except `hash`.
|
|
13
|
+
*
|
|
14
|
+
* Returns the parsed user payload when valid, throws
|
|
15
|
+
* `InstantProviderError` otherwise.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
19
|
+
import { InstantProviderError } from '../../errors.ts'
|
|
20
|
+
|
|
21
|
+
export interface WebAppUser {
|
|
22
|
+
id: number
|
|
23
|
+
first_name?: string
|
|
24
|
+
last_name?: string
|
|
25
|
+
username?: string
|
|
26
|
+
language_code?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface VerifiedWebAppInitData {
|
|
30
|
+
user?: WebAppUser
|
|
31
|
+
auth_date: Date
|
|
32
|
+
query_id?: string
|
|
33
|
+
raw: Record<string, string>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function verifyTelegramWebAppInitData(
|
|
37
|
+
initData: string,
|
|
38
|
+
botToken: string,
|
|
39
|
+
options: { maxAgeSeconds?: number } = {},
|
|
40
|
+
): VerifiedWebAppInitData {
|
|
41
|
+
const params = new URLSearchParams(initData)
|
|
42
|
+
const hash = params.get('hash')
|
|
43
|
+
if (!hash) {
|
|
44
|
+
throw new InstantProviderError('Telegram Web App initData is missing `hash`.', {
|
|
45
|
+
provider: 'telegram',
|
|
46
|
+
operation: 'verifyWebAppInitData',
|
|
47
|
+
status: 400,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
params.delete('hash')
|
|
51
|
+
const pairs: string[] = []
|
|
52
|
+
const all: Record<string, string> = {}
|
|
53
|
+
for (const [key, value] of [...params.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
54
|
+
pairs.push(`${key}=${value}`)
|
|
55
|
+
all[key] = value
|
|
56
|
+
}
|
|
57
|
+
const dataCheckString = pairs.join('\n')
|
|
58
|
+
const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest()
|
|
59
|
+
const expected = createHmac('sha256', secretKey).update(dataCheckString).digest('hex')
|
|
60
|
+
if (
|
|
61
|
+
hash.length !== expected.length ||
|
|
62
|
+
!timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(expected, 'hex'))
|
|
63
|
+
) {
|
|
64
|
+
throw new InstantProviderError('Telegram Web App initData signature does not verify.', {
|
|
65
|
+
provider: 'telegram',
|
|
66
|
+
operation: 'verifyWebAppInitData',
|
|
67
|
+
status: 401,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
const authDateRaw = all['auth_date']
|
|
71
|
+
if (!authDateRaw) {
|
|
72
|
+
throw new InstantProviderError('Telegram Web App initData missing `auth_date`.', {
|
|
73
|
+
provider: 'telegram',
|
|
74
|
+
operation: 'verifyWebAppInitData',
|
|
75
|
+
status: 400,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
const authDate = new Date(Number.parseInt(authDateRaw, 10) * 1000)
|
|
79
|
+
if (options.maxAgeSeconds !== undefined) {
|
|
80
|
+
const ageSeconds = (Date.now() - authDate.getTime()) / 1000
|
|
81
|
+
if (ageSeconds > options.maxAgeSeconds) {
|
|
82
|
+
throw new InstantProviderError('Telegram Web App initData has expired.', {
|
|
83
|
+
provider: 'telegram',
|
|
84
|
+
operation: 'verifyWebAppInitData',
|
|
85
|
+
status: 401,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
let user: WebAppUser | undefined
|
|
90
|
+
if (all['user']) {
|
|
91
|
+
try {
|
|
92
|
+
user = JSON.parse(all['user']) as WebAppUser
|
|
93
|
+
} catch {
|
|
94
|
+
// Leave user undefined when malformed — signature already verified the field.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
...(user ? { user } : {}),
|
|
99
|
+
auth_date: authDate,
|
|
100
|
+
...(all['query_id'] ? { query_id: all['query_id'] } : {}),
|
|
101
|
+
raw: all,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Build an inline-keyboard button that opens a Telegram Web App. */
|
|
106
|
+
export function webAppButton(text: string, url: string): { text: string; web_app: { url: string } } {
|
|
107
|
+
return { text, web_app: { url } }
|
|
108
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram webhook signature + parse.
|
|
3
|
+
*
|
|
4
|
+
* Signature: Telegram echoes the value of `secret_token` (set on
|
|
5
|
+
* `setWebhook`) back as the `X-Telegram-Bot-Api-Secret-Token`
|
|
6
|
+
* header. Constant-time compare against the configured secret.
|
|
7
|
+
*
|
|
8
|
+
* Parse: maps `Update` objects to the framework's `WebhookEvent`
|
|
9
|
+
* union. Anything we don't recognise → `UnknownEvent`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { timingSafeEqual } from 'node:crypto'
|
|
13
|
+
import { WebhookSignatureError } from '../../errors.ts'
|
|
14
|
+
import type {
|
|
15
|
+
FollowEvent,
|
|
16
|
+
JoinEvent,
|
|
17
|
+
LocationMessageEvent,
|
|
18
|
+
MediaMessageEvent,
|
|
19
|
+
PostbackEvent,
|
|
20
|
+
StickerMessageEvent,
|
|
21
|
+
TextMessageEvent,
|
|
22
|
+
UnknownEvent,
|
|
23
|
+
WebhookEvent,
|
|
24
|
+
WebhookEventBase,
|
|
25
|
+
} from '../../webhook_event.ts'
|
|
26
|
+
|
|
27
|
+
export function verifyTelegramSignature(
|
|
28
|
+
header: string | null | undefined,
|
|
29
|
+
expected: string | undefined,
|
|
30
|
+
): boolean {
|
|
31
|
+
// When no secret is configured, signature verification is not
|
|
32
|
+
// available — drivers should refuse to start in that case, but
|
|
33
|
+
// for safety we return false here too.
|
|
34
|
+
if (!expected) return false
|
|
35
|
+
if (!header) return false
|
|
36
|
+
if (header.length !== expected.length) return false
|
|
37
|
+
return timingSafeEqual(Buffer.from(header), Buffer.from(expected))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface TelegramUpdate {
|
|
41
|
+
update_id: number
|
|
42
|
+
message?: TelegramMessage
|
|
43
|
+
edited_message?: TelegramMessage
|
|
44
|
+
callback_query?: TelegramCallbackQuery
|
|
45
|
+
my_chat_member?: TelegramChatMemberUpdated
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface TelegramMessage {
|
|
49
|
+
message_id: number
|
|
50
|
+
date: number
|
|
51
|
+
from?: { id: number; language_code?: string }
|
|
52
|
+
chat: { id: number; type: 'private' | 'group' | 'supergroup' | 'channel' }
|
|
53
|
+
text?: string
|
|
54
|
+
caption?: string
|
|
55
|
+
photo?: Array<{ file_id: string }>
|
|
56
|
+
video?: { file_id: string }
|
|
57
|
+
audio?: { file_id: string }
|
|
58
|
+
voice?: { file_id: string }
|
|
59
|
+
document?: { file_id: string }
|
|
60
|
+
sticker?: { file_id: string; set_name?: string }
|
|
61
|
+
location?: { latitude: number; longitude: number }
|
|
62
|
+
venue?: { title: string; address: string }
|
|
63
|
+
new_chat_members?: Array<{ id: number; is_bot: boolean }>
|
|
64
|
+
left_chat_member?: { id: number; is_bot: boolean }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface TelegramCallbackQuery {
|
|
68
|
+
id: string
|
|
69
|
+
from: { id: number }
|
|
70
|
+
data?: string
|
|
71
|
+
message?: TelegramMessage
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface TelegramChatMemberUpdated {
|
|
75
|
+
chat: { id: number; type: string }
|
|
76
|
+
from: { id: number }
|
|
77
|
+
date: number
|
|
78
|
+
old_chat_member: { status: string; user: { id: number } }
|
|
79
|
+
new_chat_member: { status: string; user: { id: number; is_bot: boolean } }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function parseTelegramWebhook(rawBody: string): WebhookEvent[] {
|
|
83
|
+
let payload: TelegramUpdate
|
|
84
|
+
try {
|
|
85
|
+
payload = JSON.parse(rawBody) as TelegramUpdate
|
|
86
|
+
} catch (cause) {
|
|
87
|
+
throw new WebhookSignatureError('Telegram webhook body is not valid JSON.', { cause })
|
|
88
|
+
}
|
|
89
|
+
const events: WebhookEvent[] = []
|
|
90
|
+
const event = mapUpdate(payload)
|
|
91
|
+
if (event) events.push(event)
|
|
92
|
+
return events
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function mapUpdate(u: TelegramUpdate): WebhookEvent | undefined {
|
|
96
|
+
if (u.message) return mapMessage(u.message)
|
|
97
|
+
if (u.edited_message) return mapMessage(u.edited_message)
|
|
98
|
+
if (u.callback_query) return mapCallback(u.callback_query)
|
|
99
|
+
if (u.my_chat_member) return mapChatMember(u.my_chat_member)
|
|
100
|
+
return undefined
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function baseFromMessage(m: TelegramMessage): WebhookEventBase {
|
|
104
|
+
return {
|
|
105
|
+
provider: 'telegram',
|
|
106
|
+
userId: String(m.from?.id ?? m.chat.id),
|
|
107
|
+
timestamp: new Date(m.date * 1000),
|
|
108
|
+
source: m.chat.type === 'private' ? 'user' : m.chat.type === 'channel' ? 'unknown' : 'group',
|
|
109
|
+
...(m.chat.type !== 'private' ? { sourceId: String(m.chat.id) } : {}),
|
|
110
|
+
raw: m,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function mapMessage(m: TelegramMessage): WebhookEvent {
|
|
115
|
+
const base = baseFromMessage(m)
|
|
116
|
+
if (m.text) {
|
|
117
|
+
const event: TextMessageEvent = { ...base, type: 'message.text', text: m.text, messageId: String(m.message_id) }
|
|
118
|
+
return event
|
|
119
|
+
}
|
|
120
|
+
if (m.photo && m.photo.length > 0) {
|
|
121
|
+
const event: MediaMessageEvent = { ...base, type: 'message.image', messageId: String(m.message_id) }
|
|
122
|
+
return event
|
|
123
|
+
}
|
|
124
|
+
if (m.video) return { ...base, type: 'message.video', messageId: String(m.message_id) } as MediaMessageEvent
|
|
125
|
+
if (m.audio || m.voice) return { ...base, type: 'message.audio', messageId: String(m.message_id) } as MediaMessageEvent
|
|
126
|
+
if (m.document) return { ...base, type: 'message.file', messageId: String(m.message_id) } as MediaMessageEvent
|
|
127
|
+
if (m.sticker) {
|
|
128
|
+
const event: StickerMessageEvent = {
|
|
129
|
+
...base,
|
|
130
|
+
type: 'message.sticker',
|
|
131
|
+
messageId: String(m.message_id),
|
|
132
|
+
...(m.sticker.set_name ? { packageId: m.sticker.set_name } : {}),
|
|
133
|
+
stickerId: m.sticker.file_id,
|
|
134
|
+
}
|
|
135
|
+
return event
|
|
136
|
+
}
|
|
137
|
+
if (m.location) {
|
|
138
|
+
const event: LocationMessageEvent = {
|
|
139
|
+
...base,
|
|
140
|
+
type: 'message.location',
|
|
141
|
+
messageId: String(m.message_id),
|
|
142
|
+
latitude: m.location.latitude,
|
|
143
|
+
longitude: m.location.longitude,
|
|
144
|
+
...(m.venue?.title ? { title: m.venue.title } : {}),
|
|
145
|
+
...(m.venue?.address ? { address: m.venue.address } : {}),
|
|
146
|
+
}
|
|
147
|
+
return event
|
|
148
|
+
}
|
|
149
|
+
if (m.new_chat_members && m.new_chat_members.some((u) => u.is_bot)) {
|
|
150
|
+
const event: JoinEvent = { ...base, type: 'join' }
|
|
151
|
+
return event
|
|
152
|
+
}
|
|
153
|
+
if (m.left_chat_member?.is_bot) {
|
|
154
|
+
const event: JoinEvent = { ...base, type: 'leave' }
|
|
155
|
+
return event
|
|
156
|
+
}
|
|
157
|
+
const fallback: UnknownEvent = { ...base, type: 'unknown' }
|
|
158
|
+
return fallback
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function mapCallback(c: TelegramCallbackQuery): WebhookEvent {
|
|
162
|
+
const base: WebhookEventBase = {
|
|
163
|
+
provider: 'telegram',
|
|
164
|
+
userId: String(c.from.id),
|
|
165
|
+
timestamp: c.message ? new Date(c.message.date * 1000) : new Date(),
|
|
166
|
+
source: c.message?.chat.type === 'private' ? 'user' : 'group',
|
|
167
|
+
...(c.message && c.message.chat.type !== 'private' ? { sourceId: String(c.message.chat.id) } : {}),
|
|
168
|
+
replyToken: c.id,
|
|
169
|
+
raw: c,
|
|
170
|
+
}
|
|
171
|
+
const event: PostbackEvent = { ...base, type: 'postback', data: c.data ?? '' }
|
|
172
|
+
return event
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function mapChatMember(u: TelegramChatMemberUpdated): WebhookEvent | undefined {
|
|
176
|
+
if (!u.new_chat_member.user.is_bot) return undefined
|
|
177
|
+
const wasMember = u.old_chat_member.status === 'member' || u.old_chat_member.status === 'administrator'
|
|
178
|
+
const isMember = u.new_chat_member.status === 'member' || u.new_chat_member.status === 'administrator'
|
|
179
|
+
if (!wasMember && isMember) {
|
|
180
|
+
const event: FollowEvent = baseFollow(u, 'follow')
|
|
181
|
+
return event
|
|
182
|
+
}
|
|
183
|
+
if (wasMember && !isMember) {
|
|
184
|
+
const event: FollowEvent = baseFollow(u, 'unfollow')
|
|
185
|
+
return event
|
|
186
|
+
}
|
|
187
|
+
return undefined
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function baseFollow(u: TelegramChatMemberUpdated, type: 'follow' | 'unfollow'): FollowEvent {
|
|
191
|
+
return {
|
|
192
|
+
provider: 'telegram',
|
|
193
|
+
userId: String(u.from.id),
|
|
194
|
+
timestamp: new Date(u.date * 1000),
|
|
195
|
+
source: u.chat.type === 'private' ? 'user' : 'group',
|
|
196
|
+
...(u.chat.type !== 'private' ? { sourceId: String(u.chat.id) } : {}),
|
|
197
|
+
raw: u,
|
|
198
|
+
type,
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Public API of `@strav/instant/whatsapp/flows`.
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
flow,
|
|
5
|
+
type FlowComponent,
|
|
6
|
+
type FlowJson,
|
|
7
|
+
type FlowScreen,
|
|
8
|
+
} from './whatsapp_flow_builder.ts'
|
|
9
|
+
export {
|
|
10
|
+
decryptFlowRequest,
|
|
11
|
+
type DecryptedFlowExchange,
|
|
12
|
+
encryptFlowResponse,
|
|
13
|
+
type FlowExchangeRequest,
|
|
14
|
+
type FlowsCryptoOptions,
|
|
15
|
+
} from './whatsapp_flow_crypto.ts'
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin typed builder for WhatsApp Flow JSON. Covers the
|
|
3
|
+
* common shape (version, routing_model, screens with layout +
|
|
4
|
+
* children); apps that need the full taxonomy hand-write the
|
|
5
|
+
* JSON and pass it verbatim.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface FlowScreen {
|
|
9
|
+
id: string
|
|
10
|
+
title: string
|
|
11
|
+
terminal?: boolean
|
|
12
|
+
data?: Record<string, unknown>
|
|
13
|
+
layout: { type: 'SingleColumnLayout'; children: FlowComponent[] }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type FlowComponent =
|
|
17
|
+
| { type: 'TextHeading' | 'TextSubheading' | 'TextBody' | 'TextCaption'; text: string }
|
|
18
|
+
| { type: 'TextInput'; name: string; label: string; required?: boolean; 'input-type'?: 'text' | 'number' | 'email' | 'phone' }
|
|
19
|
+
| { type: 'TextArea'; name: string; label: string; required?: boolean }
|
|
20
|
+
| { type: 'Dropdown' | 'RadioButtonsGroup' | 'CheckboxGroup'; name: string; label: string; 'data-source': Array<{ id: string; title: string }> }
|
|
21
|
+
| { type: 'Footer'; label: string; 'on-click-action': { name: 'complete' | 'navigate' | 'data_exchange'; payload?: Record<string, unknown>; next?: { type: 'screen'; name: string } } }
|
|
22
|
+
| { type: 'Image'; src: string; 'scale-type'?: 'cover' | 'contain' }
|
|
23
|
+
|
|
24
|
+
export interface FlowJson {
|
|
25
|
+
version: '3.1' | '4.0'
|
|
26
|
+
data_api_version?: '3.0'
|
|
27
|
+
routing_model: Record<string, string[]>
|
|
28
|
+
screens: FlowScreen[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function flow(input: {
|
|
32
|
+
version?: FlowJson['version']
|
|
33
|
+
dataApiVersion?: FlowJson['data_api_version']
|
|
34
|
+
screens: FlowScreen[]
|
|
35
|
+
routing?: Record<string, string[]>
|
|
36
|
+
}): FlowJson {
|
|
37
|
+
const routing = input.routing ?? deriveRouting(input.screens)
|
|
38
|
+
return {
|
|
39
|
+
version: input.version ?? '4.0',
|
|
40
|
+
...(input.dataApiVersion ? { data_api_version: input.dataApiVersion } : {}),
|
|
41
|
+
routing_model: routing,
|
|
42
|
+
screens: input.screens,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function deriveRouting(screens: FlowScreen[]): Record<string, string[]> {
|
|
47
|
+
const map: Record<string, string[]> = {}
|
|
48
|
+
for (let i = 0; i < screens.length; i++) {
|
|
49
|
+
const screen = screens[i]
|
|
50
|
+
if (!screen) continue
|
|
51
|
+
const next = screens[i + 1]
|
|
52
|
+
map[screen.id] = next ? [next.id] : []
|
|
53
|
+
}
|
|
54
|
+
return map
|
|
55
|
+
}
|