@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,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
|
+
}
|