@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
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@strav/instant",
|
|
3
|
+
"version": "1.0.0-alpha.42",
|
|
4
|
+
"description": "Strav instant messaging — provider-agnostic abstraction for SEA messengers. Normalized OutgoingMessage + capability-flagged drivers + webhook receiver. Ships LINE (Flex / rich menus / LIFF), Telegram (inline keyboards / Web Apps), WhatsApp (interactive / Flows / templates), Messenger (templates / webview), and Zalo (ZNS / Mini App) as subpath imports.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./line": "./src/drivers/line/index.ts",
|
|
11
|
+
"./line/client": "./src/drivers/line/liff_client.ts",
|
|
12
|
+
"./telegram": "./src/drivers/telegram/index.ts",
|
|
13
|
+
"./whatsapp": "./src/drivers/whatsapp/index.ts",
|
|
14
|
+
"./whatsapp/flows": "./src/drivers/whatsapp/flows/index.ts",
|
|
15
|
+
"./messenger": "./src/drivers/messenger/index.ts",
|
|
16
|
+
"./zalo": "./src/drivers/zalo/index.ts"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"bun": ">=1.3.14"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@line/bot-sdk": "^11.0.1",
|
|
30
|
+
"@strav/kernel": "1.0.0-alpha.42"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@strav/auth": "1.0.0-alpha.42",
|
|
34
|
+
"@strav/http": "1.0.0-alpha.42"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@strav/auth": "1.0.0-alpha.42",
|
|
38
|
+
"@strav/http": "1.0.0-alpha.42",
|
|
39
|
+
"@types/bun": ">=1.3.14"
|
|
40
|
+
},
|
|
41
|
+
"peerDependenciesMeta": {
|
|
42
|
+
"@strav/auth": {
|
|
43
|
+
"optional": true
|
|
44
|
+
},
|
|
45
|
+
"@strav/http": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Public API of `@strav/instant/line`.
|
|
2
|
+
//
|
|
3
|
+
// Subpath barrel for the LINE driver. Apps import the
|
|
4
|
+
// ServiceProvider and register it in `bootstrap/providers.ts`:
|
|
5
|
+
//
|
|
6
|
+
// ```ts
|
|
7
|
+
// import { InstantProvider } from '@strav/instant'
|
|
8
|
+
// import { LineInstantProvider } from '@strav/instant/line'
|
|
9
|
+
//
|
|
10
|
+
// export default [InstantProvider, LineInstantProvider, ...]
|
|
11
|
+
// ```
|
|
12
|
+
//
|
|
13
|
+
// LINE-only surfaces (`flex`, `LineRichMenu`, `LineLiff`,
|
|
14
|
+
// `isBeaconEvent`) are exported here for apps that need them.
|
|
15
|
+
|
|
16
|
+
export { isBeaconEvent } from './line_beacon.ts'
|
|
17
|
+
export type { LineLiffConfig, LineProviderConfig } from './line_config.ts'
|
|
18
|
+
export {
|
|
19
|
+
LineDriver,
|
|
20
|
+
type LineDriverOptions,
|
|
21
|
+
} from './line_driver.ts'
|
|
22
|
+
export {
|
|
23
|
+
type BubbleInput,
|
|
24
|
+
type FlexBox,
|
|
25
|
+
type FlexBubble,
|
|
26
|
+
type FlexButton,
|
|
27
|
+
type FlexCarousel,
|
|
28
|
+
type FlexComponent,
|
|
29
|
+
type FlexContainer,
|
|
30
|
+
type FlexImage,
|
|
31
|
+
type FlexSeparator,
|
|
32
|
+
type FlexText,
|
|
33
|
+
flex,
|
|
34
|
+
} from './line_flex.ts'
|
|
35
|
+
export {
|
|
36
|
+
type LiffIdTokenClaims,
|
|
37
|
+
LineLiff,
|
|
38
|
+
type VerifyIdTokenOptions,
|
|
39
|
+
} from './line_liff.ts'
|
|
40
|
+
export {
|
|
41
|
+
liffSignInRoute,
|
|
42
|
+
type LiffSignInRouteOptions,
|
|
43
|
+
} from './liff_sign_in_route.ts'
|
|
44
|
+
export { toLineMessages } from './line_message_mapper.ts'
|
|
45
|
+
export { LineInstantProvider } from './line_provider.ts'
|
|
46
|
+
export { LineRichMenu } from './line_rich_menu.ts'
|
|
47
|
+
export {
|
|
48
|
+
parseLineWebhook,
|
|
49
|
+
verifyLineSignature,
|
|
50
|
+
} from './line_webhook.ts'
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@strav/instant/line/client` — browser-only LIFF bootstrap.
|
|
3
|
+
*
|
|
4
|
+
* Runs inside LINE's in-app webview (or an external browser when the
|
|
5
|
+
* LIFF app is opened outside LINE). Wraps the canonical handshake:
|
|
6
|
+
*
|
|
7
|
+
* 1. `liff.init({ liffId })`
|
|
8
|
+
* 2. If `!liff.isLoggedIn()` → `liff.login()` (browser navigates;
|
|
9
|
+
* this fn throws so callers stop here)
|
|
10
|
+
* 3. `liff.getIDToken()` + `liff.getProfile()`
|
|
11
|
+
* 4. POST `{ idToken }` to the server `signInEndpoint` — wired to
|
|
12
|
+
* `liffSignInRoute()` on the backend.
|
|
13
|
+
*
|
|
14
|
+
* The LIFF SDK itself is loaded from LINE's CDN — apps include this
|
|
15
|
+
* tag in the page that hosts the LIFF app:
|
|
16
|
+
*
|
|
17
|
+
* <script src="https://static.line-scdn.net/liff/edge/2/sdk.js"></script>
|
|
18
|
+
*
|
|
19
|
+
* Then bundle this module via the app's frontend toolchain (Vite,
|
|
20
|
+
* esbuild, etc.) and call `initLiffSignIn(...)` at page load.
|
|
21
|
+
*
|
|
22
|
+
* This module deliberately ships zero runtime imports — it leans on
|
|
23
|
+
* the global `liff` the CDN script sets — so it bundles to a few
|
|
24
|
+
* hundred bytes.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
interface LiffProfile {
|
|
28
|
+
userId: string
|
|
29
|
+
displayName?: string
|
|
30
|
+
pictureUrl?: string
|
|
31
|
+
statusMessage?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface LiffGlobal {
|
|
35
|
+
init(config: { liffId: string; withLoginOnExternalBrowser?: boolean }): Promise<void>
|
|
36
|
+
isLoggedIn(): boolean
|
|
37
|
+
login(options?: { redirectUri?: string }): void
|
|
38
|
+
getIDToken(): string | null
|
|
39
|
+
getProfile(): Promise<LiffProfile>
|
|
40
|
+
isInClient(): boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface LiffWindow {
|
|
44
|
+
liff?: LiffGlobal
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface InitLiffSignInOptions {
|
|
48
|
+
/** LIFF app id from the LINE developers console. */
|
|
49
|
+
liffId: string
|
|
50
|
+
/** Backend route bound to `liffSignInRoute()`. POST'd as JSON `{ idToken }`. */
|
|
51
|
+
signInEndpoint: string
|
|
52
|
+
/**
|
|
53
|
+
* Extra `RequestInit` merged into the sign-in POST. Default
|
|
54
|
+
* `{ credentials: 'include' }` — cookies on, so server-issued
|
|
55
|
+
* session cookies attach to subsequent requests. Override the
|
|
56
|
+
* `headers` to add CSRF tokens.
|
|
57
|
+
*/
|
|
58
|
+
fetch?: RequestInit
|
|
59
|
+
/**
|
|
60
|
+
* When `true`, throw instead of redirecting to LINE login if the
|
|
61
|
+
* user isn't logged in. Rare — the LIFF default UX is to auto-
|
|
62
|
+
* redirect. Useful in tests or admin tools.
|
|
63
|
+
*/
|
|
64
|
+
noAutoLogin?: boolean
|
|
65
|
+
/**
|
|
66
|
+
* Forwarded to `liff.init()` — opens an external browser when the
|
|
67
|
+
* LIFF link is tapped outside LINE. Default `false` (LINE's
|
|
68
|
+
* default; the SDK only triggers OAuth flow when needed).
|
|
69
|
+
*/
|
|
70
|
+
withLoginOnExternalBrowser?: boolean
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface LiffSignInResult {
|
|
74
|
+
/** The verified ID token. Apps rarely use this client-side — kept for advanced cases. */
|
|
75
|
+
idToken: string
|
|
76
|
+
/** LIFF-reported profile. Treat as advisory until the server returns success. */
|
|
77
|
+
profile: LiffProfile
|
|
78
|
+
/** The fetch Response from the sign-in POST. Apps inspect `response.ok` + parse the body. */
|
|
79
|
+
response: Response
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function initLiffSignIn(
|
|
83
|
+
options: InitLiffSignInOptions,
|
|
84
|
+
): Promise<LiffSignInResult> {
|
|
85
|
+
const liff = (globalThis as unknown as LiffWindow).liff
|
|
86
|
+
if (!liff) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
'initLiffSignIn: window.liff is undefined. Load the LIFF SDK before calling: <script src="https://static.line-scdn.net/liff/edge/2/sdk.js"></script>',
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
await liff.init({
|
|
93
|
+
liffId: options.liffId,
|
|
94
|
+
...(options.withLoginOnExternalBrowser ? { withLoginOnExternalBrowser: true } : {}),
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
if (!liff.isLoggedIn()) {
|
|
98
|
+
if (options.noAutoLogin) {
|
|
99
|
+
throw new Error('initLiffSignIn: user is not logged in (noAutoLogin=true).')
|
|
100
|
+
}
|
|
101
|
+
liff.login()
|
|
102
|
+
// login() navigates the browser; we throw so callers don't keep
|
|
103
|
+
// executing as if everything worked.
|
|
104
|
+
throw new Error('initLiffSignIn: redirecting to LINE login.')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const idToken = liff.getIDToken()
|
|
108
|
+
if (!idToken) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
'initLiffSignIn: liff.getIDToken() returned null. The LIFF channel must have the `openid` scope.',
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const profile = await liff.getProfile()
|
|
115
|
+
|
|
116
|
+
const init: RequestInit = {
|
|
117
|
+
method: 'POST',
|
|
118
|
+
headers: { 'content-type': 'application/json' },
|
|
119
|
+
credentials: 'include',
|
|
120
|
+
...options.fetch,
|
|
121
|
+
body: JSON.stringify({ idToken }),
|
|
122
|
+
}
|
|
123
|
+
const response = await fetch(options.signInEndpoint, init)
|
|
124
|
+
|
|
125
|
+
return { idToken, profile, response }
|
|
126
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `liffSignInRoute` — HTTP route handler that completes the LIFF
|
|
3
|
+
* sign-in handshake.
|
|
4
|
+
*
|
|
5
|
+
* Browser-side flow (see `@strav/instant/line/client`): the LIFF SDK
|
|
6
|
+
* runs inside LINE's webview, calls `liff.getIDToken()`, and POSTs the
|
|
7
|
+
* token to this route. The route:
|
|
8
|
+
*
|
|
9
|
+
* 1. Reads `{ idToken, nonce? }` from the JSON body.
|
|
10
|
+
* 2. Verifies the token via `LineLiff.verifyIdToken()` — signature,
|
|
11
|
+
* audience (channel id), expiry, and the optional nonce.
|
|
12
|
+
* 3. Calls the app-provided `onSignIn(claims, ctx)` callback. Apps
|
|
13
|
+
* do their find-or-create against the verified `claims.sub` (the
|
|
14
|
+
* LINE user id) and return their domain `User`. Returning `null`
|
|
15
|
+
* fails the request (401) — useful for "this LINE id isn't
|
|
16
|
+
* allowed into the app" gating.
|
|
17
|
+
* 4. If a `guard` is provided, calls `guard.login(ctx, user)` so
|
|
18
|
+
* subsequent requests are authenticated.
|
|
19
|
+
* 5. Returns `{ ok: true, claims }`.
|
|
20
|
+
*
|
|
21
|
+
* Apps that don't use `@strav/auth` skip the `guard` option and read
|
|
22
|
+
* the response's `claims` to issue their own session.
|
|
23
|
+
*
|
|
24
|
+
* Errors are mapped to HTTP status:
|
|
25
|
+
* - Missing / non-JSON body → 400
|
|
26
|
+
* - Missing `idToken` → 400
|
|
27
|
+
* - LIFF verification failure → 401
|
|
28
|
+
* - `onSignIn` returns null → 401
|
|
29
|
+
* - `onSignIn` throws → re-raised (framework error handler decides
|
|
30
|
+
* the response)
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { Authenticatable, Guard } from '@strav/auth'
|
|
34
|
+
import type { HttpContext } from '@strav/http'
|
|
35
|
+
import { InstantProviderError } from '../../errors.ts'
|
|
36
|
+
import type { LineLiff, LiffIdTokenClaims, VerifyIdTokenOptions } from './line_liff.ts'
|
|
37
|
+
|
|
38
|
+
export interface LiffSignInRouteOptions<U extends Authenticatable = Authenticatable> {
|
|
39
|
+
/** Configured LineLiff helper (its `channelId` is the LIFF channel id). */
|
|
40
|
+
liff: LineLiff
|
|
41
|
+
/**
|
|
42
|
+
* App-provided hook — receives the verified claims + request
|
|
43
|
+
* context, returns the domain user to sign in. Return `null` to
|
|
44
|
+
* reject the request (the route returns 401). Throw to bubble up
|
|
45
|
+
* to the framework's error handler.
|
|
46
|
+
*/
|
|
47
|
+
onSignIn: (
|
|
48
|
+
claims: LiffIdTokenClaims,
|
|
49
|
+
ctx: HttpContext,
|
|
50
|
+
) => Promise<U | null> | U | null
|
|
51
|
+
/** Optional auth guard. When set, the route calls `guard.login(ctx, user)` after `onSignIn`. */
|
|
52
|
+
guard?: Guard<U>
|
|
53
|
+
/** Forwarded to `liff.verifyIdToken(idToken, options)` (e.g. `userId` match). The `nonce` is read from the request body when present. */
|
|
54
|
+
verifyOptions?: Omit<VerifyIdTokenOptions, 'nonce'>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface SignInRequestBody {
|
|
58
|
+
idToken?: unknown
|
|
59
|
+
nonce?: unknown
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function liffSignInRoute<U extends Authenticatable = Authenticatable>(
|
|
63
|
+
options: LiffSignInRouteOptions<U>,
|
|
64
|
+
): (ctx: HttpContext) => Promise<Response> {
|
|
65
|
+
return async (ctx: HttpContext): Promise<Response> => {
|
|
66
|
+
let body: SignInRequestBody
|
|
67
|
+
try {
|
|
68
|
+
body = (await ctx.request.raw.json()) as SignInRequestBody
|
|
69
|
+
} catch {
|
|
70
|
+
return ctx.response.json({ error: 'Request body must be JSON.' }, { status: 400 })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const idToken = typeof body.idToken === 'string' ? body.idToken : null
|
|
74
|
+
if (!idToken) {
|
|
75
|
+
return ctx.response.json(
|
|
76
|
+
{ error: 'Missing or non-string `idToken` field.' },
|
|
77
|
+
{ status: 400 },
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
const nonce = typeof body.nonce === 'string' ? body.nonce : undefined
|
|
81
|
+
|
|
82
|
+
let claims: LiffIdTokenClaims
|
|
83
|
+
try {
|
|
84
|
+
claims = await options.liff.verifyIdToken(idToken, {
|
|
85
|
+
...(options.verifyOptions ?? {}),
|
|
86
|
+
...(nonce ? { nonce } : {}),
|
|
87
|
+
})
|
|
88
|
+
} catch (cause) {
|
|
89
|
+
if (cause instanceof InstantProviderError) {
|
|
90
|
+
return ctx.response.json(
|
|
91
|
+
{ error: cause.message, code: cause.code },
|
|
92
|
+
{ status: 401 },
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
throw cause
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const user = await options.onSignIn(claims, ctx)
|
|
99
|
+
if (!user) {
|
|
100
|
+
return ctx.response.json(
|
|
101
|
+
{ error: 'Sign-in rejected for this LINE identity.' },
|
|
102
|
+
{ status: 401 },
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (options.guard) {
|
|
107
|
+
await options.guard.login(ctx, user)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return ctx.response.json({ ok: true, claims })
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Beacon event type-guard helpers.
|
|
3
|
+
*
|
|
4
|
+
* Beacons are LINE-only. The normalized `WebhookEvent` already
|
|
5
|
+
* carries beacon payloads in the `BeaconEvent` variant; this
|
|
6
|
+
* module just exposes a tiny helper so apps don't repeat the
|
|
7
|
+
* discriminator check.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { BeaconEvent, WebhookEvent } from '../../webhook_event.ts'
|
|
11
|
+
|
|
12
|
+
export function isBeaconEvent(event: WebhookEvent): event is BeaconEvent {
|
|
13
|
+
return event.type === 'beacon'
|
|
14
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LINE-specific provider config.
|
|
3
|
+
*
|
|
4
|
+
* LINE splits the bot surface across two DIFFERENT channels in the
|
|
5
|
+
* Developers Console; this config makes the split explicit so
|
|
6
|
+
* apps don't accidentally point one channel's credentials at the
|
|
7
|
+
* other endpoint.
|
|
8
|
+
*
|
|
9
|
+
* 1. **Messaging API channel.** Issues `channelAccessToken` +
|
|
10
|
+
* `channelSecret`. Authenticates push / reply / multicast /
|
|
11
|
+
* broadcast / rich-menu calls, and signs the
|
|
12
|
+
* `x-line-signature` webhook header.
|
|
13
|
+
*
|
|
14
|
+
* 2. **LINE Login channel.** A separate channel that LIFF apps
|
|
15
|
+
* are bound to. The `aud` claim on a LIFF ID token is THIS
|
|
16
|
+
* channel's id — not the Messaging channel's id. The
|
|
17
|
+
* `/oauth2/v2.1/verify` endpoint rejects tokens whose `aud`
|
|
18
|
+
* doesn't match the `client_id` it receives.
|
|
19
|
+
*
|
|
20
|
+
* Apps that don't use LIFF can omit `liff` entirely. Apps that
|
|
21
|
+
* use LIFF must set `liff.channelId` to the **LINE Login channel
|
|
22
|
+
* id** — copying the Messaging channel id here is the single most
|
|
23
|
+
* common misconfiguration and will fail every verify call with an
|
|
24
|
+
* audience mismatch.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { ProviderConfig } from '../../types.ts'
|
|
28
|
+
|
|
29
|
+
export interface LineProviderConfig extends ProviderConfig {
|
|
30
|
+
driver: 'line'
|
|
31
|
+
/** Messaging API channel access token. Authenticates send / push / reply / broadcast / rich-menu calls. */
|
|
32
|
+
channelAccessToken: string
|
|
33
|
+
/** Messaging API channel secret. HMAC-SHA256 key for `x-line-signature` webhook verification. */
|
|
34
|
+
channelSecret: string
|
|
35
|
+
/**
|
|
36
|
+
* LIFF / LINE Login channel — only required if the app uses
|
|
37
|
+
* LIFF. The `channelId` here MUST be the LINE Login channel id
|
|
38
|
+
* that hosts the LIFF app, NOT the Messaging API channel id.
|
|
39
|
+
*/
|
|
40
|
+
liff?: LineLiffConfig
|
|
41
|
+
/** Override the Messaging API base URL (defaults to `https://api.line.me`). Useful in tests. */
|
|
42
|
+
apiBaseURL?: string
|
|
43
|
+
/** Override the data API base URL (defaults to `https://api-data.line.me`). Useful in tests. */
|
|
44
|
+
dataApiBaseURL?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface LineLiffConfig {
|
|
48
|
+
/**
|
|
49
|
+
* LINE Login channel id (numeric string) — the `aud` claim on
|
|
50
|
+
* every LIFF-issued ID token. Find it in the LINE Developers
|
|
51
|
+
* Console under the LINE Login channel's "Basic settings"
|
|
52
|
+
* tab, NOT the Messaging API channel.
|
|
53
|
+
*/
|
|
54
|
+
channelId: string
|
|
55
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `LineDriver` — `InstantDriver` for the LINE Messaging API.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `@line/bot-sdk`'s `LineBotClient` for send / reply / push
|
|
5
|
+
* / multicast / broadcast / profile. Webhook signature
|
|
6
|
+
* verification and event parsing live in `line_webhook.ts`; the
|
|
7
|
+
* driver delegates to those.
|
|
8
|
+
*
|
|
9
|
+
* Capability declarations match what LINE supports natively. LIFF
|
|
10
|
+
* (`liff`), Flex (`send.flex`), rich menus (`richMenu`), and
|
|
11
|
+
* beacons (`beacon`) are all included — apps reach the matching
|
|
12
|
+
* helpers via the subpath barrel:
|
|
13
|
+
*
|
|
14
|
+
* import { LineDriver, flex, LineLiff, LineRichMenu } from '@strav/instant/line'
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { LineBotClient, type messagingApi } from '@line/bot-sdk'
|
|
18
|
+
import { InstantProviderError } from '../../errors.ts'
|
|
19
|
+
import type { InstantCapability } from '../../instant_capabilities.ts'
|
|
20
|
+
import type { InstantDriver, UserProfile, WebhookOps } from '../../instant_driver.ts'
|
|
21
|
+
import type { OutgoingMessage, SendResult } from '../../message.ts'
|
|
22
|
+
import type { LineProviderConfig } from './line_config.ts'
|
|
23
|
+
import { LineLiff } from './line_liff.ts'
|
|
24
|
+
import { toLineMessages } from './line_message_mapper.ts'
|
|
25
|
+
import { LineRichMenu } from './line_rich_menu.ts'
|
|
26
|
+
import { parseLineWebhook, verifyLineSignature } from './line_webhook.ts'
|
|
27
|
+
|
|
28
|
+
const DEFAULT_CAPABILITIES: ReadonlySet<InstantCapability> = new Set<InstantCapability>([
|
|
29
|
+
'send.text',
|
|
30
|
+
'send.image',
|
|
31
|
+
'send.video',
|
|
32
|
+
'send.audio',
|
|
33
|
+
'send.location',
|
|
34
|
+
'send.sticker',
|
|
35
|
+
'send.quickReplies',
|
|
36
|
+
'send.flex',
|
|
37
|
+
'send.template',
|
|
38
|
+
'reply',
|
|
39
|
+
'push',
|
|
40
|
+
'multicast',
|
|
41
|
+
'broadcast',
|
|
42
|
+
'profile',
|
|
43
|
+
'richMenu',
|
|
44
|
+
'beacon',
|
|
45
|
+
'liff',
|
|
46
|
+
'webhook.signature',
|
|
47
|
+
'webhook.parse',
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
export interface LineDriverOptions {
|
|
51
|
+
instanceName: string
|
|
52
|
+
config: LineProviderConfig
|
|
53
|
+
/** Inject a pre-built client (tests / mocks). */
|
|
54
|
+
client?: LineBotClient
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class LineDriver implements InstantDriver {
|
|
58
|
+
readonly name = 'line'
|
|
59
|
+
readonly instanceName: string
|
|
60
|
+
readonly capabilities = DEFAULT_CAPABILITIES
|
|
61
|
+
readonly client: LineBotClient
|
|
62
|
+
readonly webhook: WebhookOps
|
|
63
|
+
|
|
64
|
+
/** Lazy LIFF helper — constructed on first access since not every app uses LIFF. */
|
|
65
|
+
private _liff: LineLiff | undefined
|
|
66
|
+
/** Lazy rich-menu helper. */
|
|
67
|
+
private _richMenu: LineRichMenu | undefined
|
|
68
|
+
|
|
69
|
+
constructor(options: LineDriverOptions) {
|
|
70
|
+
const { instanceName, config } = options
|
|
71
|
+
if (!config.channelAccessToken) {
|
|
72
|
+
throw new InstantProviderError(
|
|
73
|
+
`LineDriver: \`channelAccessToken\` is required for provider "${instanceName}".`,
|
|
74
|
+
{ provider: 'line', operation: 'init', status: 500 },
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
if (!config.channelSecret) {
|
|
78
|
+
throw new InstantProviderError(
|
|
79
|
+
`LineDriver: \`channelSecret\` is required for provider "${instanceName}".`,
|
|
80
|
+
{ provider: 'line', operation: 'init', status: 500 },
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
this.instanceName = instanceName
|
|
84
|
+
this.client =
|
|
85
|
+
options.client ??
|
|
86
|
+
LineBotClient.fromChannelAccessToken({
|
|
87
|
+
channelAccessToken: config.channelAccessToken,
|
|
88
|
+
...(config.apiBaseURL ? { apiBaseURL: config.apiBaseURL } : {}),
|
|
89
|
+
...(config.dataApiBaseURL ? { dataApiBaseURL: config.dataApiBaseURL } : {}),
|
|
90
|
+
})
|
|
91
|
+
const channelSecret = config.channelSecret
|
|
92
|
+
const liffChannelId = config.liff?.channelId
|
|
93
|
+
this.webhook = {
|
|
94
|
+
verifySignature: (rawBody, signature) =>
|
|
95
|
+
verifyLineSignature(rawBody, signature, channelSecret),
|
|
96
|
+
parse: (rawBody) => parseLineWebhook(rawBody),
|
|
97
|
+
}
|
|
98
|
+
if (liffChannelId) this._liff = new LineLiff(liffChannelId)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Send / push / reply / multicast / broadcast ─────────────────────
|
|
102
|
+
|
|
103
|
+
async send(to: string, message: OutgoingMessage): Promise<SendResult> {
|
|
104
|
+
return this.push(to, message)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async push(to: string, message: OutgoingMessage): Promise<SendResult> {
|
|
108
|
+
return this.guard('push', async () => {
|
|
109
|
+
const request: messagingApi.PushMessageRequest = {
|
|
110
|
+
to,
|
|
111
|
+
messages: toLineMessages(message),
|
|
112
|
+
}
|
|
113
|
+
const response = await this.client.pushMessage(request)
|
|
114
|
+
return {
|
|
115
|
+
provider: 'line',
|
|
116
|
+
accepted: true,
|
|
117
|
+
...((response as { sentMessages?: Array<{ id?: string }> })?.sentMessages?.[0]?.id
|
|
118
|
+
? {
|
|
119
|
+
messageId: (response as { sentMessages: Array<{ id?: string }> }).sentMessages[0]!.id,
|
|
120
|
+
}
|
|
121
|
+
: {}),
|
|
122
|
+
raw: response,
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async reply(replyToken: string, message: OutgoingMessage): Promise<SendResult> {
|
|
128
|
+
return this.guard('reply', async () => {
|
|
129
|
+
const request: messagingApi.ReplyMessageRequest = {
|
|
130
|
+
replyToken,
|
|
131
|
+
messages: toLineMessages(message),
|
|
132
|
+
}
|
|
133
|
+
const response = await this.client.replyMessage(request)
|
|
134
|
+
return { provider: 'line', accepted: true, raw: response }
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async multicast(to: readonly string[], message: OutgoingMessage): Promise<SendResult> {
|
|
139
|
+
return this.guard('multicast', async () => {
|
|
140
|
+
const request: messagingApi.MulticastRequest = {
|
|
141
|
+
to: [...to],
|
|
142
|
+
messages: toLineMessages(message),
|
|
143
|
+
}
|
|
144
|
+
const response = await this.client.multicast(request)
|
|
145
|
+
return { provider: 'line', accepted: true, raw: response }
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async broadcast(message: OutgoingMessage): Promise<SendResult> {
|
|
150
|
+
return this.guard('broadcast', async () => {
|
|
151
|
+
const request: messagingApi.BroadcastRequest = {
|
|
152
|
+
messages: toLineMessages(message),
|
|
153
|
+
}
|
|
154
|
+
const response = await this.client.broadcast(request)
|
|
155
|
+
return { provider: 'line', accepted: true, raw: response }
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async profile(userId: string): Promise<UserProfile> {
|
|
160
|
+
return this.guard('profile', async () => {
|
|
161
|
+
const r = await this.client.getProfile(userId)
|
|
162
|
+
return {
|
|
163
|
+
userId: r.userId,
|
|
164
|
+
...(r.displayName ? { displayName: r.displayName } : {}),
|
|
165
|
+
...(r.pictureUrl ? { pictureUrl: r.pictureUrl } : {}),
|
|
166
|
+
...(r.statusMessage ? { statusMessage: r.statusMessage } : {}),
|
|
167
|
+
...(r.language ? { language: r.language } : {}),
|
|
168
|
+
raw: r,
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── LINE-specific surfaces ──────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
get liff(): LineLiff {
|
|
176
|
+
if (!this._liff) {
|
|
177
|
+
throw new InstantProviderError(
|
|
178
|
+
'LineDriver: `liff.channelId` is not configured — set `config.liff.channelId` to the LINE Login channel id that hosts the LIFF app (NOT the Messaging API channel id).',
|
|
179
|
+
{ provider: 'line', operation: 'liff', status: 500 },
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
return this._liff
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
get richMenu(): LineRichMenu {
|
|
186
|
+
if (!this._richMenu) this._richMenu = new LineRichMenu(this.client)
|
|
187
|
+
return this._richMenu
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private async guard<T>(operation: string, run: () => Promise<T>): Promise<T> {
|
|
191
|
+
try {
|
|
192
|
+
return await run()
|
|
193
|
+
} catch (cause) {
|
|
194
|
+
if (cause instanceof InstantProviderError) throw cause
|
|
195
|
+
throw new InstantProviderError(`LINE \`${operation}\` failed.`, {
|
|
196
|
+
provider: 'line',
|
|
197
|
+
operation,
|
|
198
|
+
cause,
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|