@elizaos/plugin-line 2.0.0-alpha.3

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/src/index.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * LINE Plugin for ElizaOS
3
+ *
4
+ * Provides LINE Messaging API integration for ElizaOS agents,
5
+ * supporting text, flex messages, locations, and more.
6
+ */
7
+
8
+ import type { IAgentRuntime, Plugin } from "@elizaos/core";
9
+ import { logger } from "@elizaos/core";
10
+ import { sendFlexMessage, sendLocation, sendMessage } from "./actions/index.js";
11
+ import { chatContextProvider, userContextProvider } from "./providers/index.js";
12
+ import { LineService } from "./service.js";
13
+
14
+ // Re-export types and service
15
+ export * from "./types.js";
16
+ export { LineService };
17
+ export { sendMessage, sendFlexMessage, sendLocation };
18
+ export { chatContextProvider, userContextProvider };
19
+
20
+ // Account management exports
21
+ export {
22
+ DEFAULT_ACCOUNT_ID,
23
+ isLineMentionRequired,
24
+ isLineUserAllowed,
25
+ isMultiAccountEnabled,
26
+ type LineAccountConfig,
27
+ type LineGroupConfig,
28
+ type LineMultiAccountConfig,
29
+ type LineTokenResolution,
30
+ type LineTokenSource,
31
+ listEnabledLineAccounts,
32
+ listLineAccountIds,
33
+ normalizeAccountId,
34
+ type ResolvedLineAccount,
35
+ resolveDefaultLineAccountId,
36
+ resolveLineAccount,
37
+ resolveLineGroupConfig,
38
+ resolveLineSecret,
39
+ resolveLineToken,
40
+ } from "./accounts.js";
41
+
42
+ // Messaging utilities exports
43
+ export {
44
+ buildLineDeepLink,
45
+ type ChunkLineTextOpts,
46
+ type CodeBlock,
47
+ chunkLineText,
48
+ extractCodeBlocks,
49
+ extractLinks,
50
+ extractMarkdownTables,
51
+ formatCodeBlockAsText,
52
+ formatLineUser,
53
+ formatTableAsText,
54
+ getChatId,
55
+ getChatType,
56
+ hasMarkdownContent,
57
+ isGroupChat,
58
+ LINE_MAX_REPLY_MESSAGES,
59
+ LINE_TEXT_CHUNK_LIMIT,
60
+ type MarkdownLink,
61
+ type MarkdownTable,
62
+ markdownToLineChunks,
63
+ type ProcessedLineMessage,
64
+ processLineMessage,
65
+ resolveLineSystemLocation,
66
+ stripMarkdown,
67
+ truncateText,
68
+ } from "./messaging.js";
69
+
70
+ /**
71
+ * LINE plugin for ElizaOS agents.
72
+ */
73
+ const linePlugin: Plugin = {
74
+ name: "line",
75
+ description: "LINE Messaging API plugin for ElizaOS agents",
76
+
77
+ services: [LineService],
78
+ actions: [sendMessage, sendFlexMessage, sendLocation],
79
+ providers: [chatContextProvider, userContextProvider],
80
+ tests: [],
81
+
82
+ init: async (
83
+ config: Record<string, string>,
84
+ _runtime: IAgentRuntime,
85
+ ): Promise<void> => {
86
+ logger.info("Initializing LINE plugin...");
87
+
88
+ const hasAccessToken = Boolean(
89
+ config.LINE_CHANNEL_ACCESS_TOKEN || process.env.LINE_CHANNEL_ACCESS_TOKEN,
90
+ );
91
+ const hasSecret = Boolean(
92
+ config.LINE_CHANNEL_SECRET || process.env.LINE_CHANNEL_SECRET,
93
+ );
94
+
95
+ logger.info("LINE plugin configuration:");
96
+ logger.info(
97
+ ` - Access token configured: ${hasAccessToken ? "Yes" : "No"}`,
98
+ );
99
+ logger.info(` - Channel secret configured: ${hasSecret ? "Yes" : "No"}`);
100
+ logger.info(
101
+ ` - DM policy: ${config.LINE_DM_POLICY || process.env.LINE_DM_POLICY || "pairing"}`,
102
+ );
103
+ logger.info(
104
+ ` - Group policy: ${config.LINE_GROUP_POLICY || process.env.LINE_GROUP_POLICY || "allowlist"}`,
105
+ );
106
+
107
+ if (!hasAccessToken) {
108
+ logger.warn(
109
+ "LINE channel access token not configured. Set LINE_CHANNEL_ACCESS_TOKEN.",
110
+ );
111
+ }
112
+
113
+ if (!hasSecret) {
114
+ logger.warn(
115
+ "LINE channel secret not configured. Set LINE_CHANNEL_SECRET.",
116
+ );
117
+ }
118
+
119
+ logger.info("LINE plugin initialized");
120
+ },
121
+ };
122
+
123
+ export default linePlugin;
@@ -0,0 +1,507 @@
1
+ /**
2
+ * LINE text chunk limit (API supports 5000 characters per message)
3
+ */
4
+ export const LINE_TEXT_CHUNK_LIMIT = 5000;
5
+
6
+ /**
7
+ * LINE max messages per reply (API supports up to 5 messages in a reply)
8
+ */
9
+ export const LINE_MAX_REPLY_MESSAGES = 5;
10
+
11
+ /**
12
+ * Represents a markdown table extracted from text
13
+ */
14
+ export interface MarkdownTable {
15
+ headers: string[];
16
+ rows: string[][];
17
+ }
18
+
19
+ /**
20
+ * Represents a code block extracted from text
21
+ */
22
+ export interface CodeBlock {
23
+ language?: string;
24
+ code: string;
25
+ }
26
+
27
+ /**
28
+ * Represents a markdown link
29
+ */
30
+ export interface MarkdownLink {
31
+ text: string;
32
+ url: string;
33
+ }
34
+
35
+ /**
36
+ * Result of processing text for LINE
37
+ */
38
+ export interface ProcessedLineMessage {
39
+ text: string;
40
+ tables: MarkdownTable[];
41
+ codeBlocks: CodeBlock[];
42
+ links: MarkdownLink[];
43
+ }
44
+
45
+ /**
46
+ * Options for text chunking
47
+ */
48
+ export interface ChunkLineTextOpts {
49
+ limit?: number;
50
+ preserveCodeBlocks?: boolean;
51
+ }
52
+
53
+ /**
54
+ * Regex patterns for markdown detection
55
+ */
56
+ const MARKDOWN_TABLE_REGEX =
57
+ /^\|(.+)\|[\r\n]+\|[-:\s|]+\|[\r\n]+((?:\|.+\|[\r\n]*)+)/gm;
58
+ const MARKDOWN_CODE_BLOCK_REGEX = /```(\w*)\n([\s\S]*?)```/g;
59
+ const MARKDOWN_LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g;
60
+
61
+ /**
62
+ * Parses a single table row (pipe-separated values)
63
+ */
64
+ function parseTableRow(row: string): string[] {
65
+ return row
66
+ .split("|")
67
+ .map((cell) => cell.trim())
68
+ .filter((cell, index, arr) => {
69
+ // Filter out empty cells at start/end (from leading/trailing pipes)
70
+ if (index === 0 && cell === "") {
71
+ return false;
72
+ }
73
+ if (index === arr.length - 1 && cell === "") {
74
+ return false;
75
+ }
76
+ return true;
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Extracts markdown tables from text
82
+ */
83
+ export function extractMarkdownTables(text: string): {
84
+ tables: MarkdownTable[];
85
+ textWithoutTables: string;
86
+ } {
87
+ const tables: MarkdownTable[] = [];
88
+ let textWithoutTables = text;
89
+
90
+ // Reset regex state
91
+ MARKDOWN_TABLE_REGEX.lastIndex = 0;
92
+
93
+ let match: RegExpExecArray | null;
94
+ const matches: { fullMatch: string; table: MarkdownTable }[] = [];
95
+
96
+ while ((match = MARKDOWN_TABLE_REGEX.exec(text)) !== null) {
97
+ const fullMatch = match[0];
98
+ const headerLine = match[1];
99
+ const bodyLines = match[2];
100
+
101
+ const headers = parseTableRow(headerLine);
102
+ const rows = bodyLines
103
+ .trim()
104
+ .split(/[\r\n]+/)
105
+ .filter((line) => line.trim())
106
+ .map(parseTableRow);
107
+
108
+ if (headers.length > 0 && rows.length > 0) {
109
+ matches.push({
110
+ fullMatch,
111
+ table: { headers, rows },
112
+ });
113
+ }
114
+ }
115
+
116
+ // Remove tables from text in reverse order to preserve indices
117
+ for (let i = matches.length - 1; i >= 0; i--) {
118
+ const { fullMatch, table } = matches[i];
119
+ tables.unshift(table);
120
+ textWithoutTables = textWithoutTables.replace(fullMatch, "");
121
+ }
122
+
123
+ return { tables, textWithoutTables };
124
+ }
125
+
126
+ /**
127
+ * Extracts code blocks from text
128
+ */
129
+ export function extractCodeBlocks(text: string): {
130
+ codeBlocks: CodeBlock[];
131
+ textWithoutCode: string;
132
+ } {
133
+ const codeBlocks: CodeBlock[] = [];
134
+ let textWithoutCode = text;
135
+
136
+ // Reset regex state
137
+ MARKDOWN_CODE_BLOCK_REGEX.lastIndex = 0;
138
+
139
+ let match: RegExpExecArray | null;
140
+ const matches: { fullMatch: string; block: CodeBlock }[] = [];
141
+
142
+ while ((match = MARKDOWN_CODE_BLOCK_REGEX.exec(text)) !== null) {
143
+ const fullMatch = match[0];
144
+ const language = match[1] || undefined;
145
+ const code = match[2];
146
+
147
+ matches.push({
148
+ fullMatch,
149
+ block: { language, code: code.trim() },
150
+ });
151
+ }
152
+
153
+ // Remove code blocks in reverse order
154
+ for (let i = matches.length - 1; i >= 0; i--) {
155
+ const { fullMatch, block } = matches[i];
156
+ codeBlocks.unshift(block);
157
+ textWithoutCode = textWithoutCode.replace(fullMatch, "");
158
+ }
159
+
160
+ return { codeBlocks, textWithoutCode };
161
+ }
162
+
163
+ /**
164
+ * Extracts markdown links from text
165
+ */
166
+ export function extractLinks(text: string): {
167
+ links: MarkdownLink[];
168
+ textWithLinks: string;
169
+ } {
170
+ const links: MarkdownLink[] = [];
171
+
172
+ // Reset regex state
173
+ MARKDOWN_LINK_REGEX.lastIndex = 0;
174
+
175
+ let match: RegExpExecArray | null;
176
+ while ((match = MARKDOWN_LINK_REGEX.exec(text)) !== null) {
177
+ links.push({
178
+ text: match[1],
179
+ url: match[2],
180
+ });
181
+ }
182
+
183
+ // Replace markdown links with just the text (for plain text output)
184
+ const textWithLinks = text.replace(MARKDOWN_LINK_REGEX, "$1");
185
+
186
+ return { links, textWithLinks };
187
+ }
188
+
189
+ /**
190
+ * Strips markdown formatting from text
191
+ */
192
+ export function stripMarkdown(text: string): string {
193
+ let result = text;
194
+
195
+ // Remove bold: **text** or __text__
196
+ result = result.replace(/\*\*(.+?)\*\*/g, "$1");
197
+ result = result.replace(/__(.+?)__/g, "$1");
198
+
199
+ // Remove italic: *text* or _text_ (but not already processed)
200
+ result = result.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, "$1");
201
+ result = result.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, "$1");
202
+
203
+ // Remove strikethrough: ~~text~~
204
+ result = result.replace(/~~(.+?)~~/g, "$1");
205
+
206
+ // Remove headers: # Title, ## Title, etc.
207
+ result = result.replace(/^#{1,6}\s+(.+)$/gm, "$1");
208
+
209
+ // Remove blockquotes: > text
210
+ result = result.replace(/^>\s?(.*)$/gm, "$1");
211
+
212
+ // Remove horizontal rules: ---, ***, ___
213
+ result = result.replace(/^[-*_]{3,}$/gm, "");
214
+
215
+ // Remove inline code: `code`
216
+ result = result.replace(/`([^`]+)`/g, "$1");
217
+
218
+ // Clean up extra whitespace
219
+ result = result.replace(/\n{3,}/g, "\n\n");
220
+ result = result.trim();
221
+
222
+ return result;
223
+ }
224
+
225
+ /**
226
+ * Checks if text contains markdown that needs conversion
227
+ */
228
+ export function hasMarkdownContent(text: string): boolean {
229
+ // Check for tables
230
+ MARKDOWN_TABLE_REGEX.lastIndex = 0;
231
+ if (MARKDOWN_TABLE_REGEX.test(text)) {
232
+ return true;
233
+ }
234
+
235
+ // Check for code blocks
236
+ MARKDOWN_CODE_BLOCK_REGEX.lastIndex = 0;
237
+ if (MARKDOWN_CODE_BLOCK_REGEX.test(text)) {
238
+ return true;
239
+ }
240
+
241
+ // Check for other markdown patterns
242
+ if (/\*\*[^*]+\*\*/.test(text)) {
243
+ return true;
244
+ }
245
+ if (/~~[^~]+~~/.test(text)) {
246
+ return true;
247
+ }
248
+ if (/^#{1,6}\s+/m.test(text)) {
249
+ return true;
250
+ }
251
+ if (/^>\s+/m.test(text)) {
252
+ return true;
253
+ }
254
+
255
+ return false;
256
+ }
257
+
258
+ /**
259
+ * Processes text for LINE output
260
+ */
261
+ export function processLineMessage(text: string): ProcessedLineMessage {
262
+ let processedText = text;
263
+
264
+ // Extract tables
265
+ const { tables, textWithoutTables } = extractMarkdownTables(processedText);
266
+ processedText = textWithoutTables;
267
+
268
+ // Extract code blocks
269
+ const { codeBlocks, textWithoutCode } = extractCodeBlocks(processedText);
270
+ processedText = textWithoutCode;
271
+
272
+ // Handle links
273
+ const { links, textWithLinks } = extractLinks(processedText);
274
+ processedText = textWithLinks;
275
+
276
+ // Strip remaining markdown formatting
277
+ processedText = stripMarkdown(processedText);
278
+
279
+ return {
280
+ text: processedText,
281
+ tables,
282
+ codeBlocks,
283
+ links,
284
+ };
285
+ }
286
+
287
+ /**
288
+ * Splits text at the last safe break point within the limit
289
+ */
290
+ function splitAtBreakPoint(
291
+ text: string,
292
+ limit: number,
293
+ ): { chunk: string; remainder: string } {
294
+ if (text.length <= limit) {
295
+ return { chunk: text, remainder: "" };
296
+ }
297
+
298
+ // Try to find a natural break point
299
+ const searchArea = text.slice(0, limit);
300
+
301
+ // Prefer double newlines (paragraph breaks)
302
+ const doubleNewline = searchArea.lastIndexOf("\n\n");
303
+ if (doubleNewline > limit * 0.5) {
304
+ return {
305
+ chunk: text.slice(0, doubleNewline).trimEnd(),
306
+ remainder: text.slice(doubleNewline + 2).trimStart(),
307
+ };
308
+ }
309
+
310
+ // Try single newlines
311
+ const singleNewline = searchArea.lastIndexOf("\n");
312
+ if (singleNewline > limit * 0.5) {
313
+ return {
314
+ chunk: text.slice(0, singleNewline).trimEnd(),
315
+ remainder: text.slice(singleNewline + 1).trimStart(),
316
+ };
317
+ }
318
+
319
+ // Try sentence boundaries
320
+ const sentenceEnd = Math.max(
321
+ searchArea.lastIndexOf(". "),
322
+ searchArea.lastIndexOf("! "),
323
+ searchArea.lastIndexOf("? "),
324
+ );
325
+ if (sentenceEnd > limit * 0.5) {
326
+ return {
327
+ chunk: text.slice(0, sentenceEnd + 1).trimEnd(),
328
+ remainder: text.slice(sentenceEnd + 2).trimStart(),
329
+ };
330
+ }
331
+
332
+ // Try word boundaries
333
+ const space = searchArea.lastIndexOf(" ");
334
+ if (space > limit * 0.5) {
335
+ return {
336
+ chunk: text.slice(0, space).trimEnd(),
337
+ remainder: text.slice(space + 1).trimStart(),
338
+ };
339
+ }
340
+
341
+ // Hard break at limit
342
+ return {
343
+ chunk: text.slice(0, limit),
344
+ remainder: text.slice(limit),
345
+ };
346
+ }
347
+
348
+ /**
349
+ * Chunks text for LINE messages
350
+ */
351
+ export function chunkLineText(
352
+ text: string,
353
+ opts: ChunkLineTextOpts = {},
354
+ ): string[] {
355
+ const limit = opts.limit ?? LINE_TEXT_CHUNK_LIMIT;
356
+
357
+ if (!text?.trim()) {
358
+ return [];
359
+ }
360
+
361
+ const normalizedText = text.trim();
362
+ if (normalizedText.length <= limit) {
363
+ return [normalizedText];
364
+ }
365
+
366
+ const chunks: string[] = [];
367
+ let remaining = normalizedText;
368
+
369
+ while (remaining.length > 0) {
370
+ const { chunk, remainder } = splitAtBreakPoint(remaining, limit);
371
+ if (chunk) {
372
+ chunks.push(chunk);
373
+ }
374
+ remaining = remainder;
375
+ }
376
+
377
+ return chunks.filter((c) => c.length > 0);
378
+ }
379
+
380
+ /**
381
+ * Processes and chunks a markdown message for LINE
382
+ */
383
+ export function markdownToLineChunks(
384
+ markdown: string,
385
+ opts: ChunkLineTextOpts = {},
386
+ ): {
387
+ textChunks: string[];
388
+ tables: MarkdownTable[];
389
+ codeBlocks: CodeBlock[];
390
+ links: MarkdownLink[];
391
+ } {
392
+ const processed = processLineMessage(markdown);
393
+ const textChunks = chunkLineText(processed.text, opts);
394
+
395
+ return {
396
+ textChunks,
397
+ tables: processed.tables,
398
+ codeBlocks: processed.codeBlocks,
399
+ links: processed.links,
400
+ };
401
+ }
402
+
403
+ /**
404
+ * Formats a table as plain text
405
+ */
406
+ export function formatTableAsText(table: MarkdownTable): string {
407
+ const lines: string[] = [];
408
+
409
+ // Header
410
+ lines.push(table.headers.join(" | "));
411
+ lines.push("-".repeat(lines[0].length));
412
+
413
+ // Rows
414
+ for (const row of table.rows) {
415
+ lines.push(row.join(" | "));
416
+ }
417
+
418
+ return lines.join("\n");
419
+ }
420
+
421
+ /**
422
+ * Formats a code block as plain text
423
+ */
424
+ export function formatCodeBlockAsText(block: CodeBlock): string {
425
+ const langLabel = block.language ? `[${block.language}]` : "[code]";
426
+ return `${langLabel}\n${block.code}`;
427
+ }
428
+
429
+ /**
430
+ * Truncates text to a maximum length with ellipsis
431
+ */
432
+ export function truncateText(text: string, maxLength: number): string {
433
+ if (text.length <= maxLength) {
434
+ return text;
435
+ }
436
+ if (maxLength <= 3) {
437
+ return "...".slice(0, maxLength);
438
+ }
439
+ return `${text.slice(0, maxLength - 3)}...`;
440
+ }
441
+
442
+ /**
443
+ * Formats a LINE user display name
444
+ */
445
+ export function formatLineUser(displayName: string, userId: string): string {
446
+ return displayName || `User(${userId.slice(0, 8)}...)`;
447
+ }
448
+
449
+ /**
450
+ * Builds a LINE deep link URL
451
+ */
452
+ export function buildLineDeepLink(
453
+ _type: "user" | "group" | "room",
454
+ id: string,
455
+ ): string {
456
+ return `line://ti/p/${id}`;
457
+ }
458
+
459
+ /**
460
+ * Resolves the system location string for logging
461
+ */
462
+ export function resolveLineSystemLocation(params: {
463
+ chatType: "user" | "group" | "room";
464
+ chatId: string;
465
+ chatName?: string;
466
+ }): string {
467
+ const { chatType, chatId, chatName } = params;
468
+ const name = chatName || chatId.slice(0, 8);
469
+ return `LINE ${chatType}:${name}`;
470
+ }
471
+
472
+ /**
473
+ * Checks if a chat is a group chat
474
+ */
475
+ export function isGroupChat(params: {
476
+ groupId?: string;
477
+ roomId?: string;
478
+ }): boolean {
479
+ return Boolean(params.groupId || params.roomId);
480
+ }
481
+
482
+ /**
483
+ * Gets the chat ID from context
484
+ */
485
+ export function getChatId(params: {
486
+ userId: string;
487
+ groupId?: string;
488
+ roomId?: string;
489
+ }): string {
490
+ return params.groupId || params.roomId || params.userId;
491
+ }
492
+
493
+ /**
494
+ * Gets the chat type from context
495
+ */
496
+ export function getChatType(params: {
497
+ groupId?: string;
498
+ roomId?: string;
499
+ }): "user" | "group" | "room" {
500
+ if (params.groupId) {
501
+ return "group";
502
+ }
503
+ if (params.roomId) {
504
+ return "room";
505
+ }
506
+ return "user";
507
+ }