@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.
Files changed (56) hide show
  1. package/package.json +49 -0
  2. package/src/drivers/line/index.ts +50 -0
  3. package/src/drivers/line/liff_client.ts +126 -0
  4. package/src/drivers/line/liff_sign_in_route.ts +112 -0
  5. package/src/drivers/line/line_beacon.ts +14 -0
  6. package/src/drivers/line/line_config.ts +55 -0
  7. package/src/drivers/line/line_driver.ts +202 -0
  8. package/src/drivers/line/line_flex.ts +117 -0
  9. package/src/drivers/line/line_liff.ts +102 -0
  10. package/src/drivers/line/line_message_mapper.ts +118 -0
  11. package/src/drivers/line/line_provider.ts +33 -0
  12. package/src/drivers/line/line_rich_menu.ts +68 -0
  13. package/src/drivers/line/line_webhook.ts +168 -0
  14. package/src/drivers/messenger/index.ts +16 -0
  15. package/src/drivers/messenger/messenger_config.ts +20 -0
  16. package/src/drivers/messenger/messenger_driver.ts +151 -0
  17. package/src/drivers/messenger/messenger_message_mapper.ts +146 -0
  18. package/src/drivers/messenger/messenger_profile.ts +43 -0
  19. package/src/drivers/messenger/messenger_provider.ts +31 -0
  20. package/src/drivers/messenger/messenger_webhook.ts +165 -0
  21. package/src/drivers/telegram/index.ts +23 -0
  22. package/src/drivers/telegram/telegram_config.ts +19 -0
  23. package/src/drivers/telegram/telegram_driver.ts +121 -0
  24. package/src/drivers/telegram/telegram_message_mapper.ts +147 -0
  25. package/src/drivers/telegram/telegram_provider.ts +32 -0
  26. package/src/drivers/telegram/telegram_web_app.ts +108 -0
  27. package/src/drivers/telegram/telegram_webhook.ts +200 -0
  28. package/src/drivers/whatsapp/flows/index.ts +15 -0
  29. package/src/drivers/whatsapp/flows/whatsapp_flow_builder.ts +55 -0
  30. package/src/drivers/whatsapp/flows/whatsapp_flow_crypto.ts +81 -0
  31. package/src/drivers/whatsapp/index.ts +14 -0
  32. package/src/drivers/whatsapp/whatsapp_config.ts +35 -0
  33. package/src/drivers/whatsapp/whatsapp_driver.ts +111 -0
  34. package/src/drivers/whatsapp/whatsapp_message_mapper.ts +157 -0
  35. package/src/drivers/whatsapp/whatsapp_provider.ts +31 -0
  36. package/src/drivers/whatsapp/whatsapp_webhook.ts +170 -0
  37. package/src/drivers/zalo/index.ts +16 -0
  38. package/src/drivers/zalo/zalo_config.ts +37 -0
  39. package/src/drivers/zalo/zalo_driver.ts +130 -0
  40. package/src/drivers/zalo/zalo_message_mapper.ts +146 -0
  41. package/src/drivers/zalo/zalo_mini_app.ts +17 -0
  42. package/src/drivers/zalo/zalo_provider.ts +31 -0
  43. package/src/drivers/zalo/zalo_webhook.ts +139 -0
  44. package/src/errors.ts +122 -0
  45. package/src/index.ts +52 -0
  46. package/src/instant_capabilities.ts +49 -0
  47. package/src/instant_driver.ts +109 -0
  48. package/src/instant_manager.ts +113 -0
  49. package/src/instant_provider.ts +45 -0
  50. package/src/internal/fetch_json.ts +58 -0
  51. package/src/internal/meta/meta_graph_client.ts +51 -0
  52. package/src/internal/meta/meta_signature.ts +22 -0
  53. package/src/internal/meta/meta_webhook_challenge.ts +19 -0
  54. package/src/message.ts +65 -0
  55. package/src/types.ts +32 -0
  56. 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
+ }