@gakr-gakr/line 0.1.0

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 (66) hide show
  1. package/api.ts +11 -0
  2. package/autobot.plugin.json +15 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/contract-api.ts +5 -0
  5. package/index.ts +54 -0
  6. package/package.json +60 -0
  7. package/runtime-api.ts +182 -0
  8. package/secret-contract-api.ts +4 -0
  9. package/setup-api.ts +2 -0
  10. package/setup-entry.ts +9 -0
  11. package/src/account-helpers.ts +16 -0
  12. package/src/accounts.ts +187 -0
  13. package/src/actions.ts +61 -0
  14. package/src/auto-reply-delivery.ts +200 -0
  15. package/src/bindings.ts +65 -0
  16. package/src/bot-access.ts +30 -0
  17. package/src/bot-handlers.ts +620 -0
  18. package/src/bot-message-context.ts +586 -0
  19. package/src/bot.ts +70 -0
  20. package/src/card-command.ts +347 -0
  21. package/src/channel-access-token.ts +14 -0
  22. package/src/channel-api.ts +17 -0
  23. package/src/channel-shared.ts +48 -0
  24. package/src/channel.runtime.ts +3 -0
  25. package/src/channel.setup.ts +11 -0
  26. package/src/channel.ts +155 -0
  27. package/src/config-adapter.ts +29 -0
  28. package/src/config-schema.ts +81 -0
  29. package/src/download.ts +34 -0
  30. package/src/flex-templates/basic-cards.ts +395 -0
  31. package/src/flex-templates/common.ts +20 -0
  32. package/src/flex-templates/media-control-cards.ts +555 -0
  33. package/src/flex-templates/message.ts +13 -0
  34. package/src/flex-templates/schedule-cards.ts +467 -0
  35. package/src/flex-templates/types.ts +22 -0
  36. package/src/flex-templates.ts +32 -0
  37. package/src/gateway.ts +129 -0
  38. package/src/group-keys.ts +65 -0
  39. package/src/group-policy.ts +22 -0
  40. package/src/markdown-to-line.ts +416 -0
  41. package/src/monitor-durable.ts +37 -0
  42. package/src/monitor.runtime.ts +1 -0
  43. package/src/monitor.ts +507 -0
  44. package/src/outbound-media.ts +120 -0
  45. package/src/outbound.runtime.ts +12 -0
  46. package/src/outbound.ts +427 -0
  47. package/src/probe.runtime.ts +1 -0
  48. package/src/probe.ts +34 -0
  49. package/src/quick-reply-fallback.ts +10 -0
  50. package/src/reply-chunks.ts +110 -0
  51. package/src/reply-payload-transform.ts +317 -0
  52. package/src/rich-menu.ts +326 -0
  53. package/src/runtime.ts +32 -0
  54. package/src/send-receipt.ts +32 -0
  55. package/src/send.ts +531 -0
  56. package/src/setup-core.ts +149 -0
  57. package/src/setup-runtime-api.ts +9 -0
  58. package/src/setup-surface.ts +229 -0
  59. package/src/signature.ts +24 -0
  60. package/src/status.ts +37 -0
  61. package/src/template-messages.ts +333 -0
  62. package/src/types.ts +130 -0
  63. package/src/webhook-node.ts +155 -0
  64. package/src/webhook-utils.ts +10 -0
  65. package/src/webhook.ts +135 -0
  66. package/tsconfig.json +16 -0
@@ -0,0 +1,229 @@
1
+ import {
2
+ createAllowFromSection,
3
+ createStandardChannelSetupStatus,
4
+ mergeAllowFromEntries,
5
+ createSetupTranslator,
6
+ } from "autobot/plugin-sdk/setup";
7
+ import { normalizeOptionalString } from "autobot/plugin-sdk/string-coerce-runtime";
8
+ import { resolveDefaultLineAccountId } from "./accounts.js";
9
+ import {
10
+ isLineConfigured,
11
+ listLineAccountIds,
12
+ parseLineAllowFromId,
13
+ patchLineAccountConfig,
14
+ } from "./setup-core.js";
15
+ import {
16
+ DEFAULT_ACCOUNT_ID,
17
+ formatDocsLink,
18
+ resolveLineAccount,
19
+ setSetupChannelEnabled,
20
+ splitSetupEntries,
21
+ type ChannelSetupDmPolicy,
22
+ type ChannelSetupWizard,
23
+ } from "./setup-runtime-api.js";
24
+
25
+ const t = createSetupTranslator();
26
+
27
+ const channel = "line" as const;
28
+
29
+ const LINE_SETUP_HELP_LINES = [
30
+ t("wizard.line.helpOpenConsole"),
31
+ t("wizard.line.helpCopyCredentials"),
32
+ t("wizard.line.helpEnableWebhook"),
33
+ t("wizard.line.helpWebhookUrl"),
34
+ t("wizard.channels.docs", { link: formatDocsLink("/channels/line", "channels/line") }),
35
+ ];
36
+
37
+ const LINE_ALLOW_FROM_HELP_LINES = [
38
+ t("wizard.line.allowlistIntro"),
39
+ t("wizard.line.idsCaseSensitive"),
40
+ t("wizard.line.examples"),
41
+ "- U1234567890abcdef1234567890abcdef",
42
+ "- line:user:U1234567890abcdef1234567890abcdef",
43
+ t("wizard.line.multipleEntries"),
44
+ t("wizard.channels.docs", { link: formatDocsLink("/channels/line", "channels/line") }),
45
+ ];
46
+
47
+ const lineDmPolicy: ChannelSetupDmPolicy = {
48
+ label: "LINE",
49
+ channel,
50
+ policyKey: "channels.line.dmPolicy",
51
+ allowFromKey: "channels.line.allowFrom",
52
+ resolveConfigKeys: (cfg, accountId) =>
53
+ (accountId ?? resolveDefaultLineAccountId(cfg)) !== DEFAULT_ACCOUNT_ID
54
+ ? {
55
+ policyKey: `channels.line.accounts.${accountId ?? resolveDefaultLineAccountId(cfg)}.dmPolicy`,
56
+ allowFromKey: `channels.line.accounts.${accountId ?? resolveDefaultLineAccountId(cfg)}.allowFrom`,
57
+ }
58
+ : {
59
+ policyKey: "channels.line.dmPolicy",
60
+ allowFromKey: "channels.line.allowFrom",
61
+ },
62
+ getCurrent: (cfg, accountId) =>
63
+ resolveLineAccount({ cfg, accountId: accountId ?? resolveDefaultLineAccountId(cfg) }).config
64
+ .dmPolicy ?? "pairing",
65
+ setPolicy: (cfg, policy, accountId) =>
66
+ patchLineAccountConfig({
67
+ cfg,
68
+ accountId: accountId ?? resolveDefaultLineAccountId(cfg),
69
+ enabled: true,
70
+ patch:
71
+ policy === "open"
72
+ ? {
73
+ dmPolicy: "open",
74
+ allowFrom: mergeAllowFromEntries(
75
+ resolveLineAccount({
76
+ cfg,
77
+ accountId: accountId ?? resolveDefaultLineAccountId(cfg),
78
+ }).config.allowFrom,
79
+ ["*"],
80
+ ),
81
+ }
82
+ : { dmPolicy: policy },
83
+ clearFields: policy === "pairing" || policy === "disabled" ? ["allowFrom"] : undefined,
84
+ }),
85
+ };
86
+
87
+ export const lineSetupWizard: ChannelSetupWizard = {
88
+ channel,
89
+ status: createStandardChannelSetupStatus({
90
+ channelLabel: "LINE",
91
+ configuredLabel: t("wizard.channels.statusConfigured"),
92
+ unconfiguredLabel: t("wizard.channels.statusNeedsTokenSecret"),
93
+ configuredHint: t("wizard.channels.statusConfigured"),
94
+ unconfiguredHint: t("wizard.channels.statusNeedsTokenSecret"),
95
+ configuredScore: 1,
96
+ unconfiguredScore: 0,
97
+ includeStatusLine: true,
98
+ resolveConfigured: ({ cfg, accountId }) =>
99
+ isLineConfigured(cfg, accountId ?? resolveDefaultLineAccountId(cfg)),
100
+ resolveExtraStatusLines: ({ cfg }) => [`Accounts: ${listLineAccountIds(cfg).length || 0}`],
101
+ }),
102
+ introNote: {
103
+ title: t("wizard.line.messagingApiTitle"),
104
+ lines: LINE_SETUP_HELP_LINES,
105
+ shouldShow: ({ cfg, accountId }) =>
106
+ !isLineConfigured(cfg, accountId ?? resolveDefaultLineAccountId(cfg)),
107
+ },
108
+ credentials: [
109
+ {
110
+ inputKey: "token",
111
+ providerHint: channel,
112
+ credentialLabel: t("wizard.line.channelAccessToken"),
113
+ preferredEnvVar: "LINE_CHANNEL_ACCESS_TOKEN",
114
+ helpTitle: t("wizard.line.messagingApiTitle"),
115
+ helpLines: LINE_SETUP_HELP_LINES,
116
+ envPrompt: t("wizard.line.tokenEnvPrompt"),
117
+ keepPrompt: t("wizard.line.tokenKeepPrompt"),
118
+ inputPrompt: t("wizard.line.tokenInputPrompt"),
119
+ allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID,
120
+ inspect: ({ cfg, accountId }) => {
121
+ const resolved = resolveLineAccount({ cfg, accountId });
122
+ return {
123
+ accountConfigured: Boolean(
124
+ normalizeOptionalString(resolved.channelAccessToken) &&
125
+ normalizeOptionalString(resolved.channelSecret),
126
+ ),
127
+ hasConfiguredValue: Boolean(
128
+ normalizeOptionalString(resolved.config.channelAccessToken) ??
129
+ normalizeOptionalString(resolved.config.tokenFile),
130
+ ),
131
+ resolvedValue: normalizeOptionalString(resolved.channelAccessToken),
132
+ envValue:
133
+ accountId === DEFAULT_ACCOUNT_ID
134
+ ? normalizeOptionalString(process.env.LINE_CHANNEL_ACCESS_TOKEN)
135
+ : undefined,
136
+ };
137
+ },
138
+ applyUseEnv: ({ cfg, accountId }) =>
139
+ patchLineAccountConfig({
140
+ cfg,
141
+ accountId,
142
+ enabled: true,
143
+ clearFields: ["channelAccessToken", "tokenFile"],
144
+ patch: {},
145
+ }),
146
+ applySet: ({ cfg, accountId, resolvedValue }) =>
147
+ patchLineAccountConfig({
148
+ cfg,
149
+ accountId,
150
+ enabled: true,
151
+ clearFields: ["tokenFile"],
152
+ patch: { channelAccessToken: resolvedValue },
153
+ }),
154
+ },
155
+ {
156
+ inputKey: "password",
157
+ providerHint: "line-secret",
158
+ credentialLabel: t("wizard.line.channelSecret"),
159
+ preferredEnvVar: "LINE_CHANNEL_SECRET",
160
+ helpTitle: t("wizard.line.messagingApiTitle"),
161
+ helpLines: LINE_SETUP_HELP_LINES,
162
+ envPrompt: t("wizard.line.secretEnvPrompt"),
163
+ keepPrompt: t("wizard.line.secretKeepPrompt"),
164
+ inputPrompt: t("wizard.line.secretInputPrompt"),
165
+ allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID,
166
+ inspect: ({ cfg, accountId }) => {
167
+ const resolved = resolveLineAccount({ cfg, accountId });
168
+ return {
169
+ accountConfigured: Boolean(
170
+ normalizeOptionalString(resolved.channelAccessToken) &&
171
+ normalizeOptionalString(resolved.channelSecret),
172
+ ),
173
+ hasConfiguredValue: Boolean(
174
+ normalizeOptionalString(resolved.config.channelSecret) ??
175
+ normalizeOptionalString(resolved.config.secretFile),
176
+ ),
177
+ resolvedValue: normalizeOptionalString(resolved.channelSecret),
178
+ envValue:
179
+ accountId === DEFAULT_ACCOUNT_ID
180
+ ? normalizeOptionalString(process.env.LINE_CHANNEL_SECRET)
181
+ : undefined,
182
+ };
183
+ },
184
+ applyUseEnv: ({ cfg, accountId }) =>
185
+ patchLineAccountConfig({
186
+ cfg,
187
+ accountId,
188
+ enabled: true,
189
+ clearFields: ["channelSecret", "secretFile"],
190
+ patch: {},
191
+ }),
192
+ applySet: ({ cfg, accountId, resolvedValue }) =>
193
+ patchLineAccountConfig({
194
+ cfg,
195
+ accountId,
196
+ enabled: true,
197
+ clearFields: ["secretFile"],
198
+ patch: { channelSecret: resolvedValue },
199
+ }),
200
+ },
201
+ ],
202
+ allowFrom: createAllowFromSection({
203
+ helpTitle: t("wizard.line.allowlistTitle"),
204
+ helpLines: LINE_ALLOW_FROM_HELP_LINES,
205
+ message: t("wizard.line.allowFromPrompt"),
206
+ placeholder: "U1234567890abcdef1234567890abcdef",
207
+ invalidWithoutCredentialNote: t("wizard.line.allowFromInvalid"),
208
+ parseInputs: splitSetupEntries,
209
+ parseId: parseLineAllowFromId,
210
+ apply: ({ cfg, accountId, allowFrom }) =>
211
+ patchLineAccountConfig({
212
+ cfg,
213
+ accountId,
214
+ enabled: true,
215
+ patch: { dmPolicy: "allowlist", allowFrom },
216
+ }),
217
+ }),
218
+ dmPolicy: lineDmPolicy,
219
+ completionNote: {
220
+ title: t("wizard.line.webhookTitle"),
221
+ lines: [
222
+ t("wizard.line.completionEnableWebhook"),
223
+ t("wizard.line.completionDefaultWebhook"),
224
+ t("wizard.line.completionWebhookPath"),
225
+ t("wizard.channels.docs", { link: formatDocsLink("/channels/line", "channels/line") }),
226
+ ],
227
+ },
228
+ disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
229
+ };
@@ -0,0 +1,24 @@
1
+ import crypto from "node:crypto";
2
+
3
+ export function validateLineSignature(
4
+ body: string,
5
+ signature: string,
6
+ channelSecret: string,
7
+ ): boolean {
8
+ const hash = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64");
9
+ const hashBuffer = Buffer.from(hash);
10
+ const signatureBuffer = Buffer.from(signature);
11
+
12
+ // Pad to equal length before constant-time comparison to prevent
13
+ // leaking length information via early-return timing.
14
+ const maxLen = Math.max(hashBuffer.length, signatureBuffer.length);
15
+ const paddedHash = Buffer.alloc(maxLen);
16
+ const paddedSig = Buffer.alloc(maxLen);
17
+ hashBuffer.copy(paddedHash);
18
+ signatureBuffer.copy(paddedSig);
19
+
20
+ // Call timingSafeEqual unconditionally to ensure constant-time execution
21
+ // regardless of length mismatch (avoids && short-circuit timing leak).
22
+ const timingResult = crypto.timingSafeEqual(paddedHash, paddedSig);
23
+ return hashBuffer.length === signatureBuffer.length && timingResult;
24
+ }
package/src/status.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { createLazyRuntimeModule } from "autobot/plugin-sdk/lazy-runtime";
2
+ import {
3
+ buildTokenChannelStatusSummary,
4
+ createComputedAccountStatusAdapter,
5
+ createDefaultChannelRuntimeState,
6
+ createDependentCredentialStatusIssueCollector,
7
+ } from "autobot/plugin-sdk/status-helpers";
8
+ import { hasLineCredentials } from "./account-helpers.js";
9
+ import { DEFAULT_ACCOUNT_ID, type ChannelPlugin, type ResolvedLineAccount } from "./channel-api.js";
10
+
11
+ const loadLineProbeRuntime = createLazyRuntimeModule(() => import("./probe.runtime.js"));
12
+
13
+ const collectLineStatusIssues = createDependentCredentialStatusIssueCollector({
14
+ channel: "line",
15
+ dependencySourceKey: "tokenSource",
16
+ missingPrimaryMessage: "LINE channel access token not configured",
17
+ missingDependentMessage: "LINE channel secret not configured",
18
+ });
19
+
20
+ export const lineStatusAdapter: NonNullable<ChannelPlugin<ResolvedLineAccount>["status"]> =
21
+ createComputedAccountStatusAdapter<ResolvedLineAccount>({
22
+ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
23
+ collectStatusIssues: collectLineStatusIssues,
24
+ buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
25
+ probeAccount: async ({ account, timeoutMs }) =>
26
+ await (await loadLineProbeRuntime()).probeLineBot(account.channelAccessToken, timeoutMs),
27
+ resolveAccountSnapshot: ({ account }) => ({
28
+ accountId: account.accountId,
29
+ name: account.name,
30
+ enabled: account.enabled,
31
+ configured: hasLineCredentials(account),
32
+ extra: {
33
+ tokenSource: account.tokenSource,
34
+ mode: "webhook",
35
+ },
36
+ }),
37
+ });
@@ -0,0 +1,333 @@
1
+ import type { messagingApi } from "@line/bot-sdk";
2
+ import { messageAction, postbackAction, uriAction, type Action } from "./actions.js";
3
+ import type { LineTemplateMessagePayload } from "./types.js";
4
+
5
+ export { messageAction };
6
+
7
+ type TemplateMessage = messagingApi.TemplateMessage;
8
+ type ConfirmTemplate = messagingApi.ConfirmTemplate;
9
+ type ButtonsTemplate = messagingApi.ButtonsTemplate;
10
+ type CarouselTemplate = messagingApi.CarouselTemplate;
11
+ type CarouselColumn = messagingApi.CarouselColumn;
12
+ type ImageCarouselTemplate = messagingApi.ImageCarouselTemplate;
13
+ type ImageCarouselColumn = messagingApi.ImageCarouselColumn;
14
+
15
+ type TemplatePayloadAction = {
16
+ type?: "uri" | "postback" | "message";
17
+ uri?: string;
18
+ data?: string;
19
+ label: string;
20
+ };
21
+
22
+ function buildTemplatePayloadAction(action: TemplatePayloadAction): Action {
23
+ if (action.type === "uri" && action.uri) {
24
+ return uriAction(action.label, action.uri);
25
+ }
26
+ if (action.type === "postback" && action.data) {
27
+ return postbackAction(action.label, action.data, action.label);
28
+ }
29
+ return messageAction(action.label, action.data ?? action.label);
30
+ }
31
+
32
+ /**
33
+ * Create a confirm template (yes/no style dialog)
34
+ */
35
+ export function createConfirmTemplate(
36
+ text: string,
37
+ confirmAction: Action,
38
+ cancelAction: Action,
39
+ altText?: string,
40
+ ): TemplateMessage {
41
+ const template: ConfirmTemplate = {
42
+ type: "confirm",
43
+ text: text.slice(0, 240), // LINE limit
44
+ actions: [confirmAction, cancelAction],
45
+ };
46
+
47
+ return {
48
+ type: "template",
49
+ altText: altText?.slice(0, 400) ?? text.slice(0, 400),
50
+ template,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Create a button template with title, text, and action buttons
56
+ */
57
+ export function createButtonTemplate(
58
+ title: string,
59
+ text: string,
60
+ actions: Action[],
61
+ options?: {
62
+ thumbnailImageUrl?: string;
63
+ imageAspectRatio?: "rectangle" | "square";
64
+ imageSize?: "cover" | "contain";
65
+ imageBackgroundColor?: string;
66
+ defaultAction?: Action;
67
+ altText?: string;
68
+ },
69
+ ): TemplateMessage {
70
+ const hasThumbnail = Boolean(options?.thumbnailImageUrl?.trim());
71
+ const textLimit = hasThumbnail ? 160 : 60;
72
+ const template: ButtonsTemplate = {
73
+ type: "buttons",
74
+ title: title.slice(0, 40), // LINE limit
75
+ text: text.slice(0, textLimit), // LINE limit (60 if no thumbnail, 160 with thumbnail)
76
+ actions: actions.slice(0, 4), // LINE limit: max 4 actions
77
+ thumbnailImageUrl: options?.thumbnailImageUrl,
78
+ imageAspectRatio: options?.imageAspectRatio ?? "rectangle",
79
+ imageSize: options?.imageSize ?? "cover",
80
+ imageBackgroundColor: options?.imageBackgroundColor,
81
+ defaultAction: options?.defaultAction,
82
+ };
83
+
84
+ return {
85
+ type: "template",
86
+ altText: options?.altText?.slice(0, 400) ?? `${title}: ${text}`.slice(0, 400),
87
+ template,
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Create a carousel template with multiple columns
93
+ */
94
+ export function createTemplateCarousel(
95
+ columns: CarouselColumn[],
96
+ options?: {
97
+ imageAspectRatio?: "rectangle" | "square";
98
+ imageSize?: "cover" | "contain";
99
+ altText?: string;
100
+ },
101
+ ): TemplateMessage {
102
+ const template: CarouselTemplate = {
103
+ type: "carousel",
104
+ columns: columns.slice(0, 10), // LINE limit: max 10 columns
105
+ imageAspectRatio: options?.imageAspectRatio ?? "rectangle",
106
+ imageSize: options?.imageSize ?? "cover",
107
+ };
108
+
109
+ return {
110
+ type: "template",
111
+ altText: options?.altText?.slice(0, 400) ?? "View carousel",
112
+ template,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Create a carousel column for use with createTemplateCarousel
118
+ */
119
+ export function createCarouselColumn(params: {
120
+ title?: string;
121
+ text: string;
122
+ actions: Action[];
123
+ thumbnailImageUrl?: string;
124
+ imageBackgroundColor?: string;
125
+ defaultAction?: Action;
126
+ }): CarouselColumn {
127
+ return {
128
+ title: params.title?.slice(0, 40),
129
+ text: params.text.slice(0, 120), // LINE limit
130
+ actions: params.actions.slice(0, 3), // LINE limit: max 3 actions per column
131
+ thumbnailImageUrl: params.thumbnailImageUrl,
132
+ imageBackgroundColor: params.imageBackgroundColor,
133
+ defaultAction: params.defaultAction,
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Create an image carousel template (simpler, image-focused carousel)
139
+ */
140
+ export function createImageCarousel(
141
+ columns: ImageCarouselColumn[],
142
+ altText?: string,
143
+ ): TemplateMessage {
144
+ const template: ImageCarouselTemplate = {
145
+ type: "image_carousel",
146
+ columns: columns.slice(0, 10), // LINE limit: max 10 columns
147
+ };
148
+
149
+ return {
150
+ type: "template",
151
+ altText: altText?.slice(0, 400) ?? "View images",
152
+ template,
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Create an image carousel column for use with createImageCarousel
158
+ */
159
+ export function createImageCarouselColumn(imageUrl: string, action: Action): ImageCarouselColumn {
160
+ return {
161
+ imageUrl,
162
+ action,
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Create a simple yes/no confirmation dialog
168
+ */
169
+ export function createYesNoConfirm(
170
+ question: string,
171
+ options?: {
172
+ yesText?: string;
173
+ noText?: string;
174
+ yesData?: string;
175
+ noData?: string;
176
+ altText?: string;
177
+ },
178
+ ): TemplateMessage {
179
+ const yesAction: Action = options?.yesData
180
+ ? postbackAction(options.yesText ?? "Yes", options.yesData, options.yesText ?? "Yes")
181
+ : messageAction(options?.yesText ?? "Yes");
182
+
183
+ const noAction: Action = options?.noData
184
+ ? postbackAction(options.noText ?? "No", options.noData, options.noText ?? "No")
185
+ : messageAction(options?.noText ?? "No");
186
+
187
+ return createConfirmTemplate(question, yesAction, noAction, options?.altText);
188
+ }
189
+
190
+ /**
191
+ * Create a button menu with simple text buttons
192
+ */
193
+ export function createButtonMenu(
194
+ title: string,
195
+ text: string,
196
+ buttons: Array<{ label: string; text?: string }>,
197
+ options?: {
198
+ thumbnailImageUrl?: string;
199
+ altText?: string;
200
+ },
201
+ ): TemplateMessage {
202
+ const actions = buttons.slice(0, 4).map((btn) => messageAction(btn.label, btn.text));
203
+
204
+ return createButtonTemplate(title, text, actions, {
205
+ thumbnailImageUrl: options?.thumbnailImageUrl,
206
+ altText: options?.altText,
207
+ });
208
+ }
209
+
210
+ /**
211
+ * Create a button menu with URL links
212
+ */
213
+ export function createLinkMenu(
214
+ title: string,
215
+ text: string,
216
+ links: Array<{ label: string; url: string }>,
217
+ options?: {
218
+ thumbnailImageUrl?: string;
219
+ altText?: string;
220
+ },
221
+ ): TemplateMessage {
222
+ const actions = links.slice(0, 4).map((link) => uriAction(link.label, link.url));
223
+
224
+ return createButtonTemplate(title, text, actions, {
225
+ thumbnailImageUrl: options?.thumbnailImageUrl,
226
+ altText: options?.altText,
227
+ });
228
+ }
229
+
230
+ /**
231
+ * Create a simple product/item carousel
232
+ */
233
+ export function createProductCarousel(
234
+ products: Array<{
235
+ title: string;
236
+ description: string;
237
+ imageUrl?: string;
238
+ price?: string;
239
+ actionLabel?: string;
240
+ actionUrl?: string;
241
+ actionData?: string;
242
+ }>,
243
+ altText?: string,
244
+ ): TemplateMessage {
245
+ const columns = products.slice(0, 10).map((product) => {
246
+ const actions: Action[] = [];
247
+
248
+ if (product.actionUrl) {
249
+ actions.push(uriAction(product.actionLabel ?? "View", product.actionUrl));
250
+ } else if (product.actionData) {
251
+ actions.push(postbackAction(product.actionLabel ?? "Select", product.actionData));
252
+ } else {
253
+ actions.push(messageAction(product.actionLabel ?? "Select", product.title));
254
+ }
255
+
256
+ return createCarouselColumn({
257
+ title: product.title,
258
+ text: product.price
259
+ ? `${product.description}\n${product.price}`.slice(0, 120)
260
+ : product.description,
261
+ thumbnailImageUrl: product.imageUrl,
262
+ actions,
263
+ });
264
+ });
265
+
266
+ return createTemplateCarousel(columns, { altText });
267
+ }
268
+
269
+ /**
270
+ * Convert a TemplateMessagePayload from ReplyPayload to a LINE TemplateMessage
271
+ */
272
+ export function buildTemplateMessageFromPayload(
273
+ payload: LineTemplateMessagePayload,
274
+ ): TemplateMessage | null {
275
+ switch (payload.type) {
276
+ case "confirm": {
277
+ const confirmAction = payload.confirmData.startsWith("http")
278
+ ? uriAction(payload.confirmLabel, payload.confirmData)
279
+ : payload.confirmData.includes("=")
280
+ ? postbackAction(payload.confirmLabel, payload.confirmData, payload.confirmLabel)
281
+ : messageAction(payload.confirmLabel, payload.confirmData);
282
+
283
+ const cancelAction = payload.cancelData.startsWith("http")
284
+ ? uriAction(payload.cancelLabel, payload.cancelData)
285
+ : payload.cancelData.includes("=")
286
+ ? postbackAction(payload.cancelLabel, payload.cancelData, payload.cancelLabel)
287
+ : messageAction(payload.cancelLabel, payload.cancelData);
288
+
289
+ return createConfirmTemplate(payload.text, confirmAction, cancelAction, payload.altText);
290
+ }
291
+
292
+ case "buttons": {
293
+ const actions: Action[] = payload.actions
294
+ .slice(0, 4)
295
+ .map((action) => buildTemplatePayloadAction(action));
296
+
297
+ return createButtonTemplate(payload.title, payload.text, actions, {
298
+ thumbnailImageUrl: payload.thumbnailImageUrl,
299
+ altText: payload.altText,
300
+ });
301
+ }
302
+
303
+ case "carousel": {
304
+ const columns: CarouselColumn[] = payload.columns.slice(0, 10).map((col) => {
305
+ const colActions: Action[] = col.actions
306
+ .slice(0, 3)
307
+ .map((action) => buildTemplatePayloadAction(action));
308
+
309
+ return createCarouselColumn({
310
+ title: col.title,
311
+ text: col.text,
312
+ thumbnailImageUrl: col.thumbnailImageUrl,
313
+ actions: colActions,
314
+ });
315
+ });
316
+
317
+ return createTemplateCarousel(columns, { altText: payload.altText });
318
+ }
319
+
320
+ default:
321
+ return null;
322
+ }
323
+ }
324
+
325
+ export type {
326
+ TemplateMessage,
327
+ ConfirmTemplate,
328
+ ButtonsTemplate,
329
+ CarouselTemplate,
330
+ CarouselColumn,
331
+ ImageCarouselTemplate,
332
+ ImageCarouselColumn,
333
+ };