@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.
- package/api.ts +11 -0
- package/autobot.plugin.json +15 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +5 -0
- package/index.ts +54 -0
- package/package.json +60 -0
- package/runtime-api.ts +182 -0
- package/secret-contract-api.ts +4 -0
- package/setup-api.ts +2 -0
- package/setup-entry.ts +9 -0
- package/src/account-helpers.ts +16 -0
- package/src/accounts.ts +187 -0
- package/src/actions.ts +61 -0
- package/src/auto-reply-delivery.ts +200 -0
- package/src/bindings.ts +65 -0
- package/src/bot-access.ts +30 -0
- package/src/bot-handlers.ts +620 -0
- package/src/bot-message-context.ts +586 -0
- package/src/bot.ts +70 -0
- package/src/card-command.ts +347 -0
- package/src/channel-access-token.ts +14 -0
- package/src/channel-api.ts +17 -0
- package/src/channel-shared.ts +48 -0
- package/src/channel.runtime.ts +3 -0
- package/src/channel.setup.ts +11 -0
- package/src/channel.ts +155 -0
- package/src/config-adapter.ts +29 -0
- package/src/config-schema.ts +81 -0
- package/src/download.ts +34 -0
- package/src/flex-templates/basic-cards.ts +395 -0
- package/src/flex-templates/common.ts +20 -0
- package/src/flex-templates/media-control-cards.ts +555 -0
- package/src/flex-templates/message.ts +13 -0
- package/src/flex-templates/schedule-cards.ts +467 -0
- package/src/flex-templates/types.ts +22 -0
- package/src/flex-templates.ts +32 -0
- package/src/gateway.ts +129 -0
- package/src/group-keys.ts +65 -0
- package/src/group-policy.ts +22 -0
- package/src/markdown-to-line.ts +416 -0
- package/src/monitor-durable.ts +37 -0
- package/src/monitor.runtime.ts +1 -0
- package/src/monitor.ts +507 -0
- package/src/outbound-media.ts +120 -0
- package/src/outbound.runtime.ts +12 -0
- package/src/outbound.ts +427 -0
- package/src/probe.runtime.ts +1 -0
- package/src/probe.ts +34 -0
- package/src/quick-reply-fallback.ts +10 -0
- package/src/reply-chunks.ts +110 -0
- package/src/reply-payload-transform.ts +317 -0
- package/src/rich-menu.ts +326 -0
- package/src/runtime.ts +32 -0
- package/src/send-receipt.ts +32 -0
- package/src/send.ts +531 -0
- package/src/setup-core.ts +149 -0
- package/src/setup-runtime-api.ts +9 -0
- package/src/setup-surface.ts +229 -0
- package/src/signature.ts +24 -0
- package/src/status.ts +37 -0
- package/src/template-messages.ts +333 -0
- package/src/types.ts +130 -0
- package/src/webhook-node.ts +155 -0
- package/src/webhook-utils.ts +10 -0
- package/src/webhook.ts +135 -0
- 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
|
+
};
|
package/src/signature.ts
ADDED
|
@@ -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
|
+
};
|