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