@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,22 @@
1
+ import { resolveChannelGroupRequireMention } from "autobot/plugin-sdk/channel-policy";
2
+ import { resolveExactLineGroupConfigKey, type AutoBotConfig } from "./channel-api.js";
3
+
4
+ type LineGroupContext = {
5
+ cfg: AutoBotConfig;
6
+ accountId?: string | null;
7
+ groupId?: string | null;
8
+ };
9
+
10
+ export function resolveLineGroupRequireMention(params: LineGroupContext): boolean {
11
+ const exactGroupId = resolveExactLineGroupConfigKey({
12
+ cfg: params.cfg,
13
+ accountId: params.accountId,
14
+ groupId: params.groupId,
15
+ });
16
+ return resolveChannelGroupRequireMention({
17
+ cfg: params.cfg,
18
+ channel: "line",
19
+ groupId: exactGroupId ?? params.groupId,
20
+ accountId: params.accountId,
21
+ });
22
+ }
@@ -0,0 +1,416 @@
1
+ import type { messagingApi } from "@line/bot-sdk";
2
+ import { stripMarkdown } from "autobot/plugin-sdk/text-chunking";
3
+ import { createReceiptCard, toFlexMessage, type FlexBubble } from "./flex-templates.js";
4
+ export { stripMarkdown } from "autobot/plugin-sdk/text-chunking";
5
+
6
+ type FlexMessage = messagingApi.FlexMessage;
7
+ type FlexComponent = messagingApi.FlexComponent;
8
+ type FlexText = messagingApi.FlexText;
9
+ type FlexBox = messagingApi.FlexBox;
10
+
11
+ export interface ProcessedLineMessage {
12
+ /** The processed text with markdown stripped */
13
+ text: string;
14
+ /** Flex messages extracted from tables/code blocks */
15
+ flexMessages: FlexMessage[];
16
+ }
17
+
18
+ /**
19
+ * Regex patterns for markdown detection
20
+ */
21
+ const MARKDOWN_TABLE_REGEX = /^\|(.+)\|[\r\n]+\|[-:\s|]+\|[\r\n]+((?:\|.+\|[\r\n]*)+)/gm;
22
+ const MARKDOWN_CODE_BLOCK_REGEX = /```(\w*)\n([\s\S]*?)```/g;
23
+ const MARKDOWN_LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g;
24
+
25
+ /**
26
+ * Detect and extract markdown tables from text
27
+ */
28
+ export function extractMarkdownTables(text: string): {
29
+ tables: MarkdownTable[];
30
+ textWithoutTables: string;
31
+ } {
32
+ const tables: MarkdownTable[] = [];
33
+ let textWithoutTables = text;
34
+
35
+ // Reset regex state
36
+ MARKDOWN_TABLE_REGEX.lastIndex = 0;
37
+
38
+ let match: RegExpExecArray | null;
39
+ const matches: { fullMatch: string; table: MarkdownTable }[] = [];
40
+
41
+ while ((match = MARKDOWN_TABLE_REGEX.exec(text)) !== null) {
42
+ const fullMatch = match[0];
43
+ const headerLine = match[1];
44
+ const bodyLines = match[2];
45
+
46
+ const headers = parseTableRow(headerLine);
47
+ const rows = bodyLines
48
+ .trim()
49
+ .split(/[\r\n]+/)
50
+ .filter((line) => line.trim())
51
+ .map(parseTableRow);
52
+
53
+ if (headers.length > 0 && rows.length > 0) {
54
+ matches.push({
55
+ fullMatch,
56
+ table: { headers, rows },
57
+ });
58
+ }
59
+ }
60
+
61
+ // Remove tables from text in reverse order to preserve indices
62
+ for (let i = matches.length - 1; i >= 0; i--) {
63
+ const { fullMatch, table } = matches[i];
64
+ tables.unshift(table);
65
+ textWithoutTables = textWithoutTables.replace(fullMatch, "");
66
+ }
67
+
68
+ return { tables, textWithoutTables };
69
+ }
70
+
71
+ export interface MarkdownTable {
72
+ headers: string[];
73
+ rows: string[][];
74
+ }
75
+
76
+ /**
77
+ * Parse a single table row (pipe-separated values)
78
+ */
79
+ function parseTableRow(row: string): string[] {
80
+ return row
81
+ .split("|")
82
+ .map((cell) => cell.trim())
83
+ .filter((cell, index, arr) => {
84
+ // Filter out empty cells at start/end (from leading/trailing pipes)
85
+ if (index === 0 && cell === "") {
86
+ return false;
87
+ }
88
+ if (index === arr.length - 1 && cell === "") {
89
+ return false;
90
+ }
91
+ return true;
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Convert a markdown table to a LINE Flex Message bubble
97
+ */
98
+ export function convertTableToFlexBubble(table: MarkdownTable): FlexBubble {
99
+ const parseCell = (
100
+ value: string | undefined,
101
+ ): { text: string; bold: boolean; hasMarkup: boolean } => {
102
+ const raw = value?.trim() ?? "";
103
+ if (!raw) {
104
+ return { text: "-", bold: false, hasMarkup: false };
105
+ }
106
+
107
+ let hasMarkup = false;
108
+ const stripped = raw.replace(/\*\*(.+?)\*\*/g, (_, inner) => {
109
+ hasMarkup = true;
110
+ return String(inner);
111
+ });
112
+ const text = stripped.trim() || "-";
113
+ const bold = /^\*\*.+\*\*$/.test(raw);
114
+
115
+ return { text, bold, hasMarkup };
116
+ };
117
+
118
+ const headerCells = table.headers.map((header) => parseCell(header));
119
+ const rowCells = table.rows.map((row) => row.map((cell) => parseCell(cell)));
120
+ const hasInlineMarkup =
121
+ headerCells.some((cell) => cell.hasMarkup) ||
122
+ rowCells.some((row) => row.some((cell) => cell.hasMarkup));
123
+
124
+ // For simple 2-column tables, use receipt card format
125
+ if (table.headers.length === 2 && !hasInlineMarkup) {
126
+ const items = rowCells.map((row) => ({
127
+ name: row[0]?.text ?? "-",
128
+ value: row[1]?.text ?? "-",
129
+ }));
130
+
131
+ return createReceiptCard({
132
+ title: headerCells.map((cell) => cell.text).join(" / "),
133
+ items,
134
+ });
135
+ }
136
+
137
+ // For multi-column tables, create a custom layout
138
+ const headerRow: FlexComponent = {
139
+ type: "box",
140
+ layout: "horizontal",
141
+ contents: headerCells.map((cell) => ({
142
+ type: "text",
143
+ text: cell.text,
144
+ weight: "bold",
145
+ size: "sm",
146
+ color: "#333333",
147
+ flex: 1,
148
+ wrap: true,
149
+ })) as FlexText[],
150
+ paddingBottom: "sm",
151
+ } as FlexBox;
152
+
153
+ const dataRows: FlexComponent[] = rowCells.slice(0, 10).map((row, rowIndex) => {
154
+ const rowContents = table.headers.map((_, colIndex) => {
155
+ const cell = row[colIndex] ?? { text: "-", bold: false, hasMarkup: false };
156
+ return {
157
+ type: "text",
158
+ text: cell.text,
159
+ size: "sm",
160
+ color: "#666666",
161
+ flex: 1,
162
+ wrap: true,
163
+ weight: cell.bold ? "bold" : undefined,
164
+ };
165
+ }) as FlexText[];
166
+
167
+ return {
168
+ type: "box",
169
+ layout: "horizontal",
170
+ contents: rowContents,
171
+ margin: rowIndex === 0 ? "md" : "sm",
172
+ } as FlexBox;
173
+ });
174
+
175
+ return {
176
+ type: "bubble",
177
+ body: {
178
+ type: "box",
179
+ layout: "vertical",
180
+ contents: [headerRow, { type: "separator", margin: "sm" }, ...dataRows],
181
+ paddingAll: "lg",
182
+ },
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Detect and extract code blocks from text
188
+ */
189
+ export function extractCodeBlocks(text: string): {
190
+ codeBlocks: CodeBlock[];
191
+ textWithoutCode: string;
192
+ } {
193
+ const codeBlocks: CodeBlock[] = [];
194
+ let textWithoutCode = text;
195
+
196
+ // Reset regex state
197
+ MARKDOWN_CODE_BLOCK_REGEX.lastIndex = 0;
198
+
199
+ let match: RegExpExecArray | null;
200
+ const matches: { fullMatch: string; block: CodeBlock }[] = [];
201
+
202
+ while ((match = MARKDOWN_CODE_BLOCK_REGEX.exec(text)) !== null) {
203
+ const fullMatch = match[0];
204
+ const language = match[1] || undefined;
205
+ const code = match[2];
206
+
207
+ matches.push({
208
+ fullMatch,
209
+ block: { language, code: code.trim() },
210
+ });
211
+ }
212
+
213
+ // Remove code blocks in reverse order
214
+ for (let i = matches.length - 1; i >= 0; i--) {
215
+ const { fullMatch, block } = matches[i];
216
+ codeBlocks.unshift(block);
217
+ textWithoutCode = textWithoutCode.replace(fullMatch, "");
218
+ }
219
+
220
+ return { codeBlocks, textWithoutCode };
221
+ }
222
+
223
+ export interface CodeBlock {
224
+ language?: string;
225
+ code: string;
226
+ }
227
+
228
+ /**
229
+ * Convert a code block to a LINE Flex Message bubble
230
+ */
231
+ export function convertCodeBlockToFlexBubble(block: CodeBlock): FlexBubble {
232
+ const titleText = block.language ? `Code (${block.language})` : "Code";
233
+
234
+ // Truncate very long code to fit LINE's limits
235
+ const displayCode = block.code.length > 2000 ? block.code.slice(0, 2000) + "\n..." : block.code;
236
+
237
+ return {
238
+ type: "bubble",
239
+ body: {
240
+ type: "box",
241
+ layout: "vertical",
242
+ contents: [
243
+ {
244
+ type: "text",
245
+ text: titleText,
246
+ weight: "bold",
247
+ size: "sm",
248
+ color: "#666666",
249
+ } as FlexText,
250
+ {
251
+ type: "box",
252
+ layout: "vertical",
253
+ contents: [
254
+ {
255
+ type: "text",
256
+ text: displayCode,
257
+ size: "xs",
258
+ color: "#333333",
259
+ wrap: true,
260
+ } as FlexText,
261
+ ],
262
+ backgroundColor: "#F5F5F5",
263
+ paddingAll: "md",
264
+ cornerRadius: "md",
265
+ margin: "sm",
266
+ } as FlexBox,
267
+ ],
268
+ paddingAll: "lg",
269
+ },
270
+ };
271
+ }
272
+
273
+ /**
274
+ * Extract markdown links from text
275
+ */
276
+ export function extractLinks(text: string): { links: MarkdownLink[]; textWithLinks: string } {
277
+ const links: MarkdownLink[] = [];
278
+
279
+ // Reset regex state
280
+ MARKDOWN_LINK_REGEX.lastIndex = 0;
281
+
282
+ let match: RegExpExecArray | null;
283
+ while ((match = MARKDOWN_LINK_REGEX.exec(text)) !== null) {
284
+ links.push({
285
+ text: match[1],
286
+ url: match[2],
287
+ });
288
+ }
289
+
290
+ // Replace markdown links with just the text (for plain text output)
291
+ const textWithLinks = text.replace(MARKDOWN_LINK_REGEX, "$1");
292
+
293
+ return { links, textWithLinks };
294
+ }
295
+
296
+ export interface MarkdownLink {
297
+ text: string;
298
+ url: string;
299
+ }
300
+
301
+ /**
302
+ * Create a Flex Message with tappable link buttons
303
+ */
304
+ export function convertLinksToFlexBubble(links: MarkdownLink[]): FlexBubble {
305
+ const buttons: FlexComponent[] = links.slice(0, 4).map((link, index) => ({
306
+ type: "button",
307
+ action: {
308
+ type: "uri",
309
+ label: link.text.slice(0, 20), // LINE button label limit
310
+ uri: link.url,
311
+ },
312
+ style: index === 0 ? "primary" : "secondary",
313
+ margin: index > 0 ? "sm" : undefined,
314
+ }));
315
+
316
+ return {
317
+ type: "bubble",
318
+ body: {
319
+ type: "box",
320
+ layout: "vertical",
321
+ contents: [
322
+ {
323
+ type: "text",
324
+ text: "Links",
325
+ weight: "bold",
326
+ size: "md",
327
+ color: "#333333",
328
+ } as FlexText,
329
+ ],
330
+ paddingAll: "lg",
331
+ paddingBottom: "sm",
332
+ },
333
+ footer: {
334
+ type: "box",
335
+ layout: "vertical",
336
+ contents: buttons,
337
+ paddingAll: "md",
338
+ },
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Main function: Process text for LINE output
344
+ * - Extracts tables → Flex Messages
345
+ * - Extracts code blocks → Flex Messages
346
+ * - Strips remaining markdown
347
+ * - Returns processed text + Flex Messages
348
+ */
349
+ export function processLineMessage(text: string): ProcessedLineMessage {
350
+ const flexMessages: FlexMessage[] = [];
351
+ let processedText = text;
352
+
353
+ // 1. Extract and convert tables
354
+ const { tables, textWithoutTables } = extractMarkdownTables(processedText);
355
+ processedText = textWithoutTables;
356
+
357
+ for (const table of tables) {
358
+ const bubble = convertTableToFlexBubble(table);
359
+ flexMessages.push(toFlexMessage("Table", bubble));
360
+ }
361
+
362
+ // 2. Extract and convert code blocks
363
+ const { codeBlocks, textWithoutCode } = extractCodeBlocks(processedText);
364
+ processedText = textWithoutCode;
365
+
366
+ for (const block of codeBlocks) {
367
+ const bubble = convertCodeBlockToFlexBubble(block);
368
+ flexMessages.push(toFlexMessage("Code", bubble));
369
+ }
370
+
371
+ // 3. Handle links - convert [text](url) to plain text for display
372
+ // (We could also create link buttons, but that can get noisy)
373
+ const { textWithLinks } = extractLinks(processedText);
374
+ processedText = textWithLinks;
375
+
376
+ // 4. Strip remaining markdown formatting
377
+ processedText = stripMarkdown(processedText);
378
+
379
+ return {
380
+ text: processedText,
381
+ flexMessages,
382
+ };
383
+ }
384
+
385
+ /**
386
+ * Check if text contains markdown that needs conversion
387
+ */
388
+ export function hasMarkdownToConvert(text: string): boolean {
389
+ // Check for tables
390
+ MARKDOWN_TABLE_REGEX.lastIndex = 0;
391
+ if (MARKDOWN_TABLE_REGEX.test(text)) {
392
+ return true;
393
+ }
394
+
395
+ // Check for code blocks
396
+ MARKDOWN_CODE_BLOCK_REGEX.lastIndex = 0;
397
+ if (MARKDOWN_CODE_BLOCK_REGEX.test(text)) {
398
+ return true;
399
+ }
400
+
401
+ // Check for other markdown patterns
402
+ if (/\*\*[^*]+\*\*/.test(text)) {
403
+ return true;
404
+ } // bold
405
+ if (/~~[^~]+~~/.test(text)) {
406
+ return true;
407
+ } // strikethrough
408
+ if (/^#{1,6}\s+/m.test(text)) {
409
+ return true;
410
+ } // headers
411
+ if (/^>\s+/m.test(text)) {
412
+ return true;
413
+ } // blockquotes
414
+
415
+ return false;
416
+ }
@@ -0,0 +1,37 @@
1
+ import { resolveSendableOutboundReplyParts } from "autobot/plugin-sdk/reply-payload";
2
+ import type { ReplyPayload } from "autobot/plugin-sdk/reply-runtime";
3
+ import type { LineChannelData } from "./types.js";
4
+
5
+ export type LineDurableReplyOptions = {
6
+ to: string;
7
+ };
8
+
9
+ function hasLineChannelData(payload: ReplyPayload): boolean {
10
+ const lineData = payload.channelData?.line as LineChannelData | undefined;
11
+ return Boolean(lineData && Object.keys(lineData).length > 0);
12
+ }
13
+
14
+ export function resolveLineDurableReplyOptions(params: {
15
+ payload: ReplyPayload;
16
+ infoKind: string;
17
+ to: string;
18
+ replyToken?: string | null;
19
+ replyTokenUsed: boolean;
20
+ }): LineDurableReplyOptions | false {
21
+ if (params.infoKind !== "final") {
22
+ return false;
23
+ }
24
+ if (params.replyToken && !params.replyTokenUsed) {
25
+ return false;
26
+ }
27
+ if (hasLineChannelData(params.payload)) {
28
+ return false;
29
+ }
30
+ const reply = resolveSendableOutboundReplyParts(params.payload);
31
+ if (reply.hasMedia || !reply.hasText) {
32
+ return false;
33
+ }
34
+ return {
35
+ to: params.to,
36
+ };
37
+ }
@@ -0,0 +1 @@
1
+ export { monitorLineProvider } from "./monitor.js";