@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/__tests__/integration.test.ts +782 -0
- package/build.ts +16 -0
- package/dist/index.js +49 -0
- package/package.json +34 -0
- package/src/accounts.ts +462 -0
- package/src/actions/index.ts +7 -0
- package/src/actions/sendFlexMessage.ts +243 -0
- package/src/actions/sendLocation.ts +223 -0
- package/src/actions/sendMessage.ts +202 -0
- package/src/index.ts +123 -0
- package/src/messaging.ts +507 -0
- package/src/providers/chatContext.ts +110 -0
- package/src/providers/index.ts +6 -0
- package/src/providers/userContext.ts +99 -0
- package/src/service.ts +578 -0
- package/src/types.ts +417 -0
- package/tsconfig.json +22 -0
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;
|
package/src/messaging.ts
ADDED
|
@@ -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
|
+
}
|