@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,117 @@
1
+ /**
2
+ * `flex` — typed builder for LINE Flex Messages.
3
+ *
4
+ * Flex JSON is rich enough that hand-writing it gets painful fast.
5
+ * The builders re-export the SDK's underlying types via
6
+ * pass-through functions that fill in the `type` discriminator and
7
+ * narrow option bags. The output is plain JSON assignable to
8
+ * `FlexContainer` / `FlexComponent`, which the driver wraps in a
9
+ * `FlexMessage` when sending.
10
+ *
11
+ * Apps that need a shape not covered here (very rare — there are
12
+ * only ~9 components) hand-write the JSON; everything composes.
13
+ */
14
+
15
+ import type { messagingApi } from '@line/bot-sdk'
16
+
17
+ type FlexBubble = messagingApi.FlexBubble
18
+ type FlexCarousel = messagingApi.FlexCarousel
19
+ type FlexBox = messagingApi.FlexBox
20
+ type FlexText = messagingApi.FlexText
21
+ type FlexButton = messagingApi.FlexButton
22
+ type FlexImage = messagingApi.FlexImage
23
+ type FlexSeparator = messagingApi.FlexSeparator
24
+ type FlexComponent = messagingApi.FlexComponent
25
+ type FlexContainer = messagingApi.FlexContainer
26
+ type Action = messagingApi.Action
27
+
28
+ export interface BubbleInput {
29
+ size?: FlexBubble['size']
30
+ direction?: FlexBubble['direction']
31
+ header?: FlexBox
32
+ hero?: FlexImage | FlexBox
33
+ body?: FlexBox
34
+ footer?: FlexBox
35
+ styles?: FlexBubble['styles']
36
+ }
37
+
38
+ function bubble(input: BubbleInput): FlexBubble {
39
+ return { type: 'bubble', ...input } as FlexBubble
40
+ }
41
+
42
+ function carousel(bubbles: FlexBubble[]): FlexCarousel {
43
+ return { type: 'carousel', contents: bubbles }
44
+ }
45
+
46
+ function box(
47
+ layout: FlexBox['layout'],
48
+ contents: FlexComponent[],
49
+ options: Omit<FlexBox, 'type' | 'layout' | 'contents'> = {},
50
+ ): FlexBox {
51
+ return { type: 'box', layout, contents, ...options }
52
+ }
53
+
54
+ function text(value: string, options: Omit<FlexText, 'type' | 'text'> = {}): FlexText {
55
+ return { type: 'text', text: value, ...options }
56
+ }
57
+
58
+ function button(options: Omit<FlexButton, 'type'>): FlexButton {
59
+ return { type: 'button', ...options }
60
+ }
61
+
62
+ function image(url: string, options: Omit<FlexImage, 'type' | 'url'> = {}): FlexImage {
63
+ return { type: 'image', url, ...options }
64
+ }
65
+
66
+ function separator(options: Omit<FlexSeparator, 'type'> = {}): FlexSeparator {
67
+ return { type: 'separator', ...options }
68
+ }
69
+
70
+ function messageAction(label: string, text: string): Action {
71
+ return { type: 'message', label, text }
72
+ }
73
+
74
+ function postbackAction(
75
+ label: string,
76
+ data: string,
77
+ options: { displayText?: string } = {},
78
+ ): Action {
79
+ return { type: 'postback', label, data, ...options }
80
+ }
81
+
82
+ function uriAction(label: string, uri: string): Action {
83
+ return { type: 'uri', label, uri }
84
+ }
85
+
86
+ /**
87
+ * Flex builder facade. Importable as a namespace:
88
+ *
89
+ * import { flex } from '@strav/instant/line'
90
+ * const bubble = flex.bubble({ body: flex.box('vertical', [flex.text('Hi')]) })
91
+ */
92
+ export const flex = {
93
+ bubble,
94
+ carousel,
95
+ box,
96
+ text,
97
+ button,
98
+ image,
99
+ separator,
100
+ action: {
101
+ message: messageAction,
102
+ postback: postbackAction,
103
+ uri: uriAction,
104
+ },
105
+ }
106
+
107
+ export type {
108
+ FlexBox,
109
+ FlexBubble,
110
+ FlexButton,
111
+ FlexCarousel,
112
+ FlexComponent,
113
+ FlexContainer,
114
+ FlexImage,
115
+ FlexSeparator,
116
+ FlexText,
117
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * LIFF ID-token verification.
3
+ *
4
+ * LIFF apps run client-side inside LINE's in-app webview. The
5
+ * frontend obtains an ID token via `liff.getIDToken()` and posts it
6
+ * to the backend; the backend MUST verify the token with LINE
7
+ * (signature, audience, expiry) before trusting any claim from it.
8
+ *
9
+ * `verifyIdToken` POSTs to `https://api.line.me/oauth2/v2.1/verify`
10
+ * with the channel id as `client_id`. LINE returns the decoded
11
+ * claims (`sub`, `name`, `picture`, `email` when the user
12
+ * consented to the email scope) when the token is valid; we throw
13
+ * `InstantProviderError` otherwise.
14
+ *
15
+ * Never trust a userId received directly from a LIFF frontend —
16
+ * always validate via this helper first.
17
+ */
18
+
19
+ import { InstantProviderError } from '../../errors.ts'
20
+
21
+ const LINE_VERIFY_ENDPOINT = 'https://api.line.me/oauth2/v2.1/verify'
22
+
23
+ export interface LiffIdTokenClaims {
24
+ /** LINE user id (`sub` claim — same as `userId` from Messaging API). */
25
+ sub: string
26
+ /** Display name. Always present when the token is valid. */
27
+ name?: string
28
+ /** Profile picture URL. Present when the user has one. */
29
+ picture?: string
30
+ /** Email — only present when the channel has the `email` scope and the user consented. */
31
+ email?: string
32
+ /** Audience claim — should equal the channel id we passed. */
33
+ aud: string
34
+ /** Issuer (`https://access.line.me`). */
35
+ iss: string
36
+ /** Expiry (seconds since epoch). */
37
+ exp: number
38
+ /** Issued-at (seconds since epoch). */
39
+ iat: number
40
+ /** Raw claims object from LINE — keep the rest for advanced cases. */
41
+ raw: Record<string, unknown>
42
+ }
43
+
44
+ export interface VerifyIdTokenOptions {
45
+ /** Required nonce match — LINE's response must echo this value. */
46
+ nonce?: string
47
+ /** Required user id match — convenience check on top of `sub`. */
48
+ userId?: string
49
+ /** Override the verify endpoint (tests). */
50
+ endpoint?: string
51
+ }
52
+
53
+ export class LineLiff {
54
+ constructor(private readonly channelId: string) {
55
+ if (!channelId) {
56
+ throw new InstantProviderError(
57
+ 'LineLiff: `channelId` is required for ID-token verification.',
58
+ {
59
+ provider: 'line',
60
+ operation: 'liff.verifyIdToken',
61
+ status: 500,
62
+ },
63
+ )
64
+ }
65
+ }
66
+
67
+ async verifyIdToken(
68
+ idToken: string,
69
+ options: VerifyIdTokenOptions = {},
70
+ ): Promise<LiffIdTokenClaims> {
71
+ const body = new URLSearchParams({ id_token: idToken, client_id: this.channelId })
72
+ if (options.nonce) body.set('nonce', options.nonce)
73
+ if (options.userId) body.set('user_id', options.userId)
74
+
75
+ const response = await fetch(options.endpoint ?? LINE_VERIFY_ENDPOINT, {
76
+ method: 'POST',
77
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
78
+ body: body.toString(),
79
+ })
80
+
81
+ const payload = (await response.json().catch(() => null)) as Record<string, unknown> | null
82
+ if (!response.ok || !payload) {
83
+ throw new InstantProviderError('LineLiff: ID-token verification failed.', {
84
+ provider: 'line',
85
+ operation: 'liff.verifyIdToken',
86
+ status: response.status,
87
+ context: { response: payload },
88
+ })
89
+ }
90
+ return {
91
+ sub: String(payload.sub ?? ''),
92
+ ...(payload.name ? { name: String(payload.name) } : {}),
93
+ ...(payload.picture ? { picture: String(payload.picture) } : {}),
94
+ ...(payload.email ? { email: String(payload.email) } : {}),
95
+ aud: String(payload.aud ?? ''),
96
+ iss: String(payload.iss ?? ''),
97
+ exp: Number(payload.exp ?? 0),
98
+ iat: Number(payload.iat ?? 0),
99
+ raw: payload,
100
+ }
101
+ }
102
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Map the framework's LCD `OutgoingMessage` shape onto LINE's
3
+ * `Message` JSON.
4
+ *
5
+ * LINE accepts 1-5 `Message` objects per push / reply / multicast
6
+ * call; the mapper expands a single `OutgoingMessage` into the
7
+ * matching number of LINE messages: one for text, one per
8
+ * attachment, plus quick replies attached to the LAST message
9
+ * (LINE only honours `quickReply` on the last item of a batch).
10
+ *
11
+ * `raw` is a passthrough — apps that need a shape outside the LCD
12
+ * (Flex bubbles built by `flex.*`, template messages, imagemaps)
13
+ * set `message.raw` to the LINE JSON and it goes through verbatim.
14
+ * When `raw` is set, the LCD fields are ignored.
15
+ */
16
+
17
+ import type { messagingApi } from '@line/bot-sdk'
18
+ import { InstantProviderError } from '../../errors.ts'
19
+ import type { Attachment, OutgoingMessage, QuickReply } from '../../message.ts'
20
+
21
+ type LineMessage = messagingApi.Message
22
+ type LineQuickReply = messagingApi.QuickReply
23
+ type LineAction = messagingApi.Action
24
+
25
+ export function toLineMessages(message: OutgoingMessage): LineMessage[] {
26
+ if (message.raw !== undefined) {
27
+ const raw = message.raw as LineMessage | LineMessage[]
28
+ return Array.isArray(raw) ? raw : [raw]
29
+ }
30
+
31
+ const messages: LineMessage[] = []
32
+
33
+ if (message.text) {
34
+ messages.push({ type: 'text', text: message.text })
35
+ }
36
+
37
+ if (message.attachments) {
38
+ for (const a of message.attachments) {
39
+ messages.push(toLineAttachment(a))
40
+ }
41
+ }
42
+
43
+ if (messages.length === 0) {
44
+ throw new InstantProviderError(
45
+ 'LineDriver: cannot send an empty message — set `text`, `attachments`, or `raw`.',
46
+ { provider: 'line', operation: 'send', status: 400 },
47
+ )
48
+ }
49
+
50
+ if (message.quickReplies && message.quickReplies.length > 0) {
51
+ const last = messages[messages.length - 1] as LineMessage & { quickReply?: LineQuickReply }
52
+ last.quickReply = toLineQuickReply(message.quickReplies)
53
+ }
54
+
55
+ return messages
56
+ }
57
+
58
+ function toLineAttachment(a: Attachment): LineMessage {
59
+ switch (a.type) {
60
+ case 'image':
61
+ return {
62
+ type: 'image',
63
+ originalContentUrl: a.url,
64
+ previewImageUrl: a.previewUrl ?? a.url,
65
+ }
66
+ case 'video':
67
+ return {
68
+ type: 'video',
69
+ originalContentUrl: a.url,
70
+ previewImageUrl: a.previewUrl ?? a.url,
71
+ }
72
+ case 'audio':
73
+ return {
74
+ type: 'audio',
75
+ originalContentUrl: a.url,
76
+ duration: a.durationMs ?? 0,
77
+ }
78
+ case 'file':
79
+ // LINE has no first-class "file" message — fall back to a text link.
80
+ return { type: 'text', text: a.fileName ? `${a.fileName}\n${a.url}` : a.url }
81
+ case 'location':
82
+ return {
83
+ type: 'location',
84
+ title: a.title ?? 'Location',
85
+ address: a.address ?? '',
86
+ latitude: a.latitude,
87
+ longitude: a.longitude,
88
+ }
89
+ case 'sticker':
90
+ return { type: 'sticker', packageId: a.packageId, stickerId: a.stickerId }
91
+ }
92
+ }
93
+
94
+ function toLineQuickReply(replies: readonly QuickReply[]): LineQuickReply {
95
+ return {
96
+ items: replies.slice(0, 13).map((r) => ({
97
+ type: 'action',
98
+ ...(r.iconUrl ? { imageUrl: r.iconUrl } : {}),
99
+ action: toLineAction(r.label, r.action),
100
+ })),
101
+ }
102
+ }
103
+
104
+ function toLineAction(label: string, action: QuickReply['action']): LineAction {
105
+ switch (action.type) {
106
+ case 'message':
107
+ return { type: 'message', label, text: action.text }
108
+ case 'postback':
109
+ return {
110
+ type: 'postback',
111
+ label,
112
+ data: action.data,
113
+ ...(action.displayText ? { displayText: action.displayText } : {}),
114
+ }
115
+ case 'uri':
116
+ return { type: 'uri', label, uri: action.uri }
117
+ }
118
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `LineInstantProvider` — `ServiceProvider` that registers the
3
+ * LINE driver factory on the `InstantManager`.
4
+ *
5
+ * Apps list this AFTER `InstantProvider` in
6
+ * `bootstrap/providers.ts`. Driver instances construct lazily on
7
+ * first `instant.use(name)` call.
8
+ */
9
+
10
+ import { type Application, ServiceProvider } from '@strav/kernel'
11
+ import { InstantConfigError } from '../../errors.ts'
12
+ import { InstantManager } from '../../instant_manager.ts'
13
+ import type { LineProviderConfig } from './line_config.ts'
14
+ import { LineDriver } from './line_driver.ts'
15
+
16
+ export class LineInstantProvider extends ServiceProvider {
17
+ override readonly name = 'instant-line'
18
+ override readonly dependencies = ['instant']
19
+
20
+ override register(app: Application): void {
21
+ const manager = app.resolve(InstantManager)
22
+ manager.extend('line', ({ instanceName, config }) => {
23
+ const cfg = config as LineProviderConfig
24
+ if (!cfg.channelAccessToken || !cfg.channelSecret) {
25
+ throw new InstantConfigError(
26
+ `LineInstantProvider: \`channelAccessToken\` and \`channelSecret\` are required for provider "${instanceName}".`,
27
+ { context: { instanceName } },
28
+ )
29
+ }
30
+ return new LineDriver({ instanceName, config: cfg })
31
+ })
32
+ }
33
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * LINE rich-menu CRUD wrappers.
3
+ *
4
+ * Rich menus are persistent image-based menu bars at the bottom of
5
+ * the LINE chat UI. Each menu is created with a JSON definition
6
+ * (size, tappable areas, actions), then an image is uploaded for
7
+ * it, and finally it's either set as the default menu or linked
8
+ * to specific users.
9
+ *
10
+ * Wraps `@line/bot-sdk`'s `LineBotClient`. Apps that need narrower
11
+ * surfaces (alias management, bulk linking) call into the client
12
+ * directly via `driver.client`.
13
+ */
14
+
15
+ import type { LineBotClient, messagingApi } from '@line/bot-sdk'
16
+ import { InstantProviderError } from '../../errors.ts'
17
+
18
+ export class LineRichMenu {
19
+ constructor(private readonly client: LineBotClient) {}
20
+
21
+ create(input: messagingApi.RichMenuRequest): Promise<string> {
22
+ return this.guard('richMenu.create', () =>
23
+ this.client.createRichMenu(input).then((r) => r.richMenuId),
24
+ )
25
+ }
26
+
27
+ delete(richMenuId: string): Promise<void> {
28
+ return this.guard('richMenu.delete', () =>
29
+ this.client.deleteRichMenu(richMenuId).then(() => undefined),
30
+ )
31
+ }
32
+
33
+ setImage(richMenuId: string, image: Blob): Promise<void> {
34
+ return this.guard('richMenu.setImage', () =>
35
+ this.client.setRichMenuImage(richMenuId, image).then(() => undefined),
36
+ )
37
+ }
38
+
39
+ setDefault(richMenuId: string): Promise<void> {
40
+ return this.guard('richMenu.setDefault', () =>
41
+ this.client.setDefaultRichMenu(richMenuId).then(() => undefined),
42
+ )
43
+ }
44
+
45
+ linkToUser(userId: string, richMenuId: string): Promise<void> {
46
+ return this.guard('richMenu.linkToUser', () =>
47
+ this.client.linkRichMenuIdToUser(userId, richMenuId).then(() => undefined),
48
+ )
49
+ }
50
+
51
+ unlinkFromUser(userId: string): Promise<void> {
52
+ return this.guard('richMenu.unlinkFromUser', () =>
53
+ this.client.unlinkRichMenuIdFromUser(userId).then(() => undefined),
54
+ )
55
+ }
56
+
57
+ private async guard<T>(operation: string, run: () => Promise<T>): Promise<T> {
58
+ try {
59
+ return await run()
60
+ } catch (cause) {
61
+ throw new InstantProviderError(`LINE \`${operation}\` failed.`, {
62
+ provider: 'line',
63
+ operation,
64
+ cause,
65
+ })
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * LINE webhook helpers — signature verification + event parsing
3
+ * into the framework's normalized `WebhookEvent` union.
4
+ *
5
+ * Signature verification uses `@line/bot-sdk`'s `validateSignature`
6
+ * (HMAC-SHA256 of the raw body against the channel secret,
7
+ * base64-encoded, constant-time compared against the
8
+ * `x-line-signature` header).
9
+ *
10
+ * Parsing turns each `events[]` entry from the LINE callback body
11
+ * into one normalized `WebhookEvent`. Variants the framework
12
+ * doesn't model (membership, video play complete, module attach,
13
+ * etc.) map to `{ type: 'unknown', raw }` so apps that want them
14
+ * can still reach the original payload via `event.raw`.
15
+ */
16
+
17
+ import { type webhook as lineWebhook, validateSignature } from '@line/bot-sdk'
18
+ import type {
19
+ BeaconEvent,
20
+ FollowEvent,
21
+ JoinEvent,
22
+ LocationMessageEvent,
23
+ MediaMessageEvent,
24
+ PostbackEvent,
25
+ StickerMessageEvent,
26
+ TextMessageEvent,
27
+ UnknownEvent,
28
+ WebhookEvent,
29
+ WebhookEventBase,
30
+ } from '../../webhook_event.ts'
31
+
32
+ type LineSource = lineWebhook.Source
33
+ type LineEvent = lineWebhook.Event
34
+
35
+ export function verifyLineSignature(
36
+ rawBody: string,
37
+ signature: string | null | undefined,
38
+ channelSecret: string,
39
+ ): boolean {
40
+ if (!signature) return false
41
+ try {
42
+ return validateSignature(rawBody, channelSecret, signature)
43
+ } catch {
44
+ return false
45
+ }
46
+ }
47
+
48
+ export function parseLineWebhook(rawBody: string): WebhookEvent[] {
49
+ const callback = JSON.parse(rawBody) as lineWebhook.CallbackRequest
50
+ if (!callback?.events || !Array.isArray(callback.events)) return []
51
+ return callback.events.map(toWebhookEvent)
52
+ }
53
+
54
+ function toWebhookEvent(event: LineEvent): WebhookEvent {
55
+ const base = buildBase(event)
56
+
57
+ switch (event.type) {
58
+ case 'message': {
59
+ const message = event.message
60
+ switch (message.type) {
61
+ case 'text':
62
+ return {
63
+ ...base,
64
+ type: 'message.text',
65
+ messageId: message.id,
66
+ text: message.text,
67
+ } satisfies TextMessageEvent
68
+ case 'image':
69
+ case 'video':
70
+ case 'audio':
71
+ case 'file': {
72
+ const media: MediaMessageEvent = {
73
+ ...base,
74
+ type: `message.${message.type}` as MediaMessageEvent['type'],
75
+ messageId: message.id,
76
+ }
77
+ return media
78
+ }
79
+ case 'location':
80
+ return {
81
+ ...base,
82
+ type: 'message.location',
83
+ messageId: message.id,
84
+ latitude: message.latitude,
85
+ longitude: message.longitude,
86
+ ...(message.title ? { title: message.title } : {}),
87
+ ...(message.address ? { address: message.address } : {}),
88
+ } satisfies LocationMessageEvent
89
+ case 'sticker':
90
+ return {
91
+ ...base,
92
+ type: 'message.sticker',
93
+ messageId: message.id,
94
+ packageId: message.packageId,
95
+ stickerId: message.stickerId,
96
+ } satisfies StickerMessageEvent
97
+ default:
98
+ return { ...base, type: 'unknown' } satisfies UnknownEvent
99
+ }
100
+ }
101
+ case 'postback':
102
+ return {
103
+ ...base,
104
+ type: 'postback',
105
+ data: event.postback.data,
106
+ } satisfies PostbackEvent
107
+ case 'follow':
108
+ return { ...base, type: 'follow' } satisfies FollowEvent
109
+ case 'unfollow':
110
+ return { ...base, type: 'unfollow' } satisfies FollowEvent
111
+ case 'join':
112
+ return { ...base, type: 'join' } satisfies JoinEvent
113
+ case 'leave':
114
+ return { ...base, type: 'leave' } satisfies JoinEvent
115
+ case 'beacon':
116
+ return {
117
+ ...base,
118
+ type: 'beacon',
119
+ beacon: {
120
+ hwid: event.beacon.hwid,
121
+ kind: event.beacon.type as BeaconEvent['beacon']['kind'],
122
+ ...(event.beacon.dm ? { dm: event.beacon.dm } : {}),
123
+ },
124
+ } satisfies BeaconEvent
125
+ default:
126
+ return { ...base, type: 'unknown' } satisfies UnknownEvent
127
+ }
128
+ }
129
+
130
+ function buildBase(event: LineEvent): WebhookEventBase {
131
+ const { userId, kind, sourceId } = readSource(event.source)
132
+ const replyToken = (event as { replyToken?: string }).replyToken
133
+ return {
134
+ provider: 'line',
135
+ userId,
136
+ timestamp: new Date(event.timestamp),
137
+ source: kind,
138
+ ...(sourceId ? { sourceId } : {}),
139
+ ...(replyToken ? { replyToken } : {}),
140
+ raw: event,
141
+ }
142
+ }
143
+
144
+ function readSource(source: LineSource | undefined): {
145
+ userId: string
146
+ kind: WebhookEventBase['source']
147
+ sourceId?: string
148
+ } {
149
+ if (!source) return { userId: '', kind: 'unknown' }
150
+ switch (source.type) {
151
+ case 'user':
152
+ return { userId: source.userId ?? '', kind: 'user' }
153
+ case 'group':
154
+ return {
155
+ userId: source.userId ?? '',
156
+ kind: 'group',
157
+ sourceId: source.groupId,
158
+ }
159
+ case 'room':
160
+ return {
161
+ userId: source.userId ?? '',
162
+ kind: 'room',
163
+ sourceId: source.roomId,
164
+ }
165
+ default:
166
+ return { userId: '', kind: 'unknown' }
167
+ }
168
+ }
@@ -0,0 +1,16 @@
1
+ // Public API of `@strav/instant/messenger`.
2
+
3
+ export type { MessengerProviderConfig } from './messenger_config.ts'
4
+ export { MessengerDriver, type MessengerDriverOptions } from './messenger_driver.ts'
5
+ export { toMessengerPayload } from './messenger_message_mapper.ts'
6
+ export {
7
+ MessengerBotProfile,
8
+ type PersistentMenuEntry,
9
+ type PersistentMenuItem,
10
+ } from './messenger_profile.ts'
11
+ export { MessengerInstantProvider } from './messenger_provider.ts'
12
+ export {
13
+ parseMessengerWebhook,
14
+ verifyMessengerChallenge,
15
+ verifyMessengerSignature,
16
+ } from './messenger_webhook.ts'
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `MessengerProviderConfig` — config shape consumed by
3
+ * `MessengerInstantProvider`.
4
+ *
5
+ * Distinct from the WhatsApp Cloud config even though both
6
+ * use Meta's Graph: Messenger is page-scoped (`pageId` +
7
+ * `pageAccessToken`), WhatsApp is WABA-scoped.
8
+ */
9
+
10
+ import type { ProviderConfig } from '../../types.ts'
11
+
12
+ export interface MessengerProviderConfig extends ProviderConfig {
13
+ driver: 'messenger'
14
+ pageId: string
15
+ pageAccessToken: string
16
+ appSecret: string
17
+ verifyToken: string
18
+ /** Defaults to `v20.0`. */
19
+ apiVersion?: string
20
+ }