@kodelyth/line 2026.5.39 → 2026.5.42
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/channel-plugin-api.ts +1 -0
- package/contract-api.ts +5 -0
- package/dist/accounts-CD4A1FE7.js +105 -0
- package/dist/api.js +11 -0
- package/dist/basic-cards-BISytiSa.js +307 -0
- package/dist/card-command-dQBX3fVN.js +240 -0
- package/dist/channel-DV5h44-j.js +649 -0
- package/dist/channel-plugin-api.js +2 -0
- package/dist/channel.runtime-Cc-v3szZ.js +4 -0
- package/dist/contract-api.js +2 -0
- package/dist/index.js +45 -0
- package/dist/markdown-to-line-CC3BU6CC.js +810 -0
- package/dist/monitor-Ci8Hg8ay.js +1485 -0
- package/dist/monitor.runtime-t6-QvlDB.js +2 -0
- package/dist/outbound.runtime-D1CxEvcL.js +2 -0
- package/dist/probe-BPSs_A_8.js +30 -0
- package/dist/probe.runtime-7u2o9QN5.js +2 -0
- package/dist/reply-payload-transform-CDuBzoT4.js +855 -0
- package/dist/runtime-api.js +291 -0
- package/dist/schedule-cards-D-yZMHDE.js +359 -0
- package/dist/secret-contract-api.js +5 -0
- package/dist/setup-api.js +2 -0
- package/dist/setup-entry.js +11 -0
- package/dist/setup-surface-CHfQ6Z4i.js +282 -0
- package/index.ts +53 -0
- package/klaw.plugin.json +2 -329
- package/package.json +4 -4
- package/runtime-api.ts +179 -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.test.ts +288 -0
- package/src/accounts.ts +187 -0
- package/src/actions.ts +61 -0
- package/src/auto-reply-delivery.test.ts +253 -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.test.ts +1094 -0
- package/src/bot-handlers.ts +620 -0
- package/src/bot-message-context.test.ts +420 -0
- package/src/bot-message-context.ts +586 -0
- package/src/bot.ts +66 -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-setup-status.contract.test.ts +70 -0
- package/src/channel-shared.ts +48 -0
- package/src/channel.logout.test.ts +145 -0
- package/src/channel.runtime.ts +3 -0
- package/src/channel.sendPayload.test.ts +659 -0
- package/src/channel.setup.ts +11 -0
- package/src/channel.status.test.ts +63 -0
- package/src/channel.ts +155 -0
- package/src/config-adapter.ts +29 -0
- package/src/config-schema.test.ts +53 -0
- package/src/config-schema.ts +81 -0
- package/src/download.test.ts +164 -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.test.ts +123 -0
- package/src/group-keys.ts +65 -0
- package/src/group-policy.ts +22 -0
- package/src/markdown-to-line.test.ts +348 -0
- package/src/markdown-to-line.ts +416 -0
- package/src/message-cards.test.ts +204 -0
- package/src/monitor-durable.test.ts +57 -0
- package/src/monitor-durable.ts +37 -0
- package/src/monitor.lifecycle.test.ts +499 -0
- package/src/monitor.runtime.ts +1 -0
- package/src/monitor.ts +507 -0
- package/src/outbound-media.test.ts +194 -0
- package/src/outbound-media.ts +120 -0
- package/src/outbound.runtime.ts +12 -0
- package/src/outbound.ts +427 -0
- package/src/probe.contract.test.ts +9 -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.test.ts +180 -0
- package/src/reply-chunks.ts +110 -0
- package/src/reply-payload-transform.test.ts +392 -0
- package/src/reply-payload-transform.ts +317 -0
- package/src/rich-menu.test.ts +315 -0
- package/src/rich-menu.ts +326 -0
- package/src/runtime.ts +32 -0
- package/src/send-receipt.ts +32 -0
- package/src/send.test.ts +453 -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.test.ts +481 -0
- package/src/setup-surface.ts +229 -0
- package/src/signature.test.ts +34 -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.test.ts +598 -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
- package/api.js +0 -7
- package/channel-plugin-api.js +0 -7
- package/contract-api.js +0 -7
- package/index.js +0 -7
- package/runtime-api.js +0 -7
- package/secret-contract-api.js +0 -7
- package/setup-api.js +0 -7
- package/setup-entry.js +0 -7
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { ChannelAccountSnapshot } from "../api.js";
|
|
3
|
+
import { lineStatusAdapter } from "./status.js";
|
|
4
|
+
|
|
5
|
+
function collectIssues(accounts: ChannelAccountSnapshot[]) {
|
|
6
|
+
const collect = lineStatusAdapter.collectStatusIssues;
|
|
7
|
+
if (!collect) {
|
|
8
|
+
throw new Error("LINE plugin status collector is unavailable");
|
|
9
|
+
}
|
|
10
|
+
return collect(accounts);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("linePlugin status.collectStatusIssues", () => {
|
|
14
|
+
it("does not warn when a sanitized snapshot is configured", () => {
|
|
15
|
+
expect(
|
|
16
|
+
collectIssues([
|
|
17
|
+
{
|
|
18
|
+
accountId: "default",
|
|
19
|
+
configured: true,
|
|
20
|
+
tokenSource: "env",
|
|
21
|
+
},
|
|
22
|
+
]),
|
|
23
|
+
).toStrictEqual([]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("reports missing access token when the snapshot is unconfigured and tokenSource is none", () => {
|
|
27
|
+
expect(
|
|
28
|
+
collectIssues([
|
|
29
|
+
{
|
|
30
|
+
accountId: "default",
|
|
31
|
+
configured: false,
|
|
32
|
+
tokenSource: "none",
|
|
33
|
+
},
|
|
34
|
+
]),
|
|
35
|
+
).toEqual([
|
|
36
|
+
{
|
|
37
|
+
channel: "line",
|
|
38
|
+
accountId: "default",
|
|
39
|
+
kind: "config",
|
|
40
|
+
message: "LINE channel access token not configured",
|
|
41
|
+
},
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("reports missing secret when the snapshot is unconfigured but a token source exists", () => {
|
|
46
|
+
expect(
|
|
47
|
+
collectIssues([
|
|
48
|
+
{
|
|
49
|
+
accountId: "default",
|
|
50
|
+
configured: false,
|
|
51
|
+
tokenSource: "env",
|
|
52
|
+
},
|
|
53
|
+
]),
|
|
54
|
+
).toEqual([
|
|
55
|
+
{
|
|
56
|
+
channel: "line",
|
|
57
|
+
accountId: "default",
|
|
58
|
+
kind: "config",
|
|
59
|
+
message: "LINE channel secret not configured",
|
|
60
|
+
},
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
});
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createChatChannelPlugin } from "klaw/plugin-sdk/channel-core";
|
|
2
|
+
import { createPairingPrefixStripper } from "klaw/plugin-sdk/channel-pairing";
|
|
3
|
+
import { createRestrictSendersChannelSecurity } from "klaw/plugin-sdk/channel-policy";
|
|
4
|
+
import { createEmptyChannelDirectoryAdapter } from "klaw/plugin-sdk/directory-runtime";
|
|
5
|
+
import { createLazyRuntimeModule } from "klaw/plugin-sdk/lazy-runtime";
|
|
6
|
+
import { resolveLineAccount } from "./accounts.js";
|
|
7
|
+
import { lineBindingsAdapter } from "./bindings.js";
|
|
8
|
+
import { type ChannelPlugin, type ResolvedLineAccount } from "./channel-api.js";
|
|
9
|
+
import { lineChannelPluginCommon } from "./channel-shared.js";
|
|
10
|
+
import { lineGatewayAdapter } from "./gateway.js";
|
|
11
|
+
import { resolveLineGroupRequireMention } from "./group-policy.js";
|
|
12
|
+
import { lineMessageAdapter, lineOutboundAdapter } from "./outbound.js";
|
|
13
|
+
import { hasLineDirectives, parseLineDirectives } from "./reply-payload-transform.js";
|
|
14
|
+
import { getLineRuntime } from "./runtime.js";
|
|
15
|
+
import { lineSetupAdapter } from "./setup-core.js";
|
|
16
|
+
import { lineSetupWizard } from "./setup-surface.js";
|
|
17
|
+
import { lineStatusAdapter } from "./status.js";
|
|
18
|
+
|
|
19
|
+
const loadLineChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
|
|
20
|
+
|
|
21
|
+
const lineSecurityAdapter = createRestrictSendersChannelSecurity<ResolvedLineAccount>({
|
|
22
|
+
channelKey: "line",
|
|
23
|
+
resolveDmPolicy: (account) => account.config.dmPolicy,
|
|
24
|
+
resolveDmAllowFrom: (account) => account.config.allowFrom,
|
|
25
|
+
resolveGroupPolicy: (account) => account.config.groupPolicy,
|
|
26
|
+
surface: "LINE groups",
|
|
27
|
+
openScope: "any member in groups",
|
|
28
|
+
groupPolicyPath: "channels.line.groupPolicy",
|
|
29
|
+
groupAllowFromPath: "channels.line.groupAllowFrom",
|
|
30
|
+
mentionGated: false,
|
|
31
|
+
policyPathSuffix: "dmPolicy",
|
|
32
|
+
approveHint: "klaw pairing approve line <code>",
|
|
33
|
+
normalizeDmEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const linePlugin: ChannelPlugin<ResolvedLineAccount> = createChatChannelPlugin({
|
|
37
|
+
base: {
|
|
38
|
+
id: "line",
|
|
39
|
+
...lineChannelPluginCommon,
|
|
40
|
+
setupWizard: lineSetupWizard,
|
|
41
|
+
groups: {
|
|
42
|
+
resolveRequireMention: resolveLineGroupRequireMention,
|
|
43
|
+
},
|
|
44
|
+
messaging: {
|
|
45
|
+
targetPrefixes: ["line"],
|
|
46
|
+
normalizeTarget: (target) => {
|
|
47
|
+
const trimmed = target.trim();
|
|
48
|
+
if (!trimmed) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
return trimmed.replace(/^line:(group|room|user):/i, "").replace(/^line:/i, "");
|
|
52
|
+
},
|
|
53
|
+
resolveInboundConversation: lineBindingsAdapter.resolveInboundConversation,
|
|
54
|
+
transformReplyPayload: ({ payload }) => {
|
|
55
|
+
if (!payload.text || !hasLineDirectives(payload.text)) {
|
|
56
|
+
return payload;
|
|
57
|
+
}
|
|
58
|
+
return parseLineDirectives(payload);
|
|
59
|
+
},
|
|
60
|
+
targetResolver: {
|
|
61
|
+
looksLikeId: (id) => {
|
|
62
|
+
const trimmed = id?.trim();
|
|
63
|
+
if (!trimmed) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
return /^[UCR][a-f0-9]{32}$/i.test(trimmed) || /^line:/i.test(trimmed);
|
|
67
|
+
},
|
|
68
|
+
hint: "<userId|groupId|roomId>",
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
directory: createEmptyChannelDirectoryAdapter(),
|
|
72
|
+
setup: lineSetupAdapter,
|
|
73
|
+
status: lineStatusAdapter,
|
|
74
|
+
gateway: lineGatewayAdapter,
|
|
75
|
+
message: lineMessageAdapter,
|
|
76
|
+
bindings: lineBindingsAdapter,
|
|
77
|
+
conversationBindings: {
|
|
78
|
+
defaultTopLevelPlacement: "current",
|
|
79
|
+
},
|
|
80
|
+
agentPrompt: {
|
|
81
|
+
messageToolHints: () => [
|
|
82
|
+
"",
|
|
83
|
+
"### LINE Rich Messages",
|
|
84
|
+
"LINE supports rich visual messages. Use these directives in your reply when appropriate:",
|
|
85
|
+
"",
|
|
86
|
+
"**Quick Replies** (bottom button suggestions):",
|
|
87
|
+
" [[quick_replies: Option 1, Option 2, Option 3]]",
|
|
88
|
+
"",
|
|
89
|
+
"**Location** (map pin):",
|
|
90
|
+
" [[location: Place Name | Address | latitude | longitude]]",
|
|
91
|
+
"",
|
|
92
|
+
"**Confirm Dialog** (yes/no prompt):",
|
|
93
|
+
" [[confirm: Question text? | Yes Label | No Label]]",
|
|
94
|
+
"",
|
|
95
|
+
"**Button Menu** (title + text + buttons):",
|
|
96
|
+
" [[buttons: Title | Description | Btn1:action1, Btn2:https://url.com]]",
|
|
97
|
+
"",
|
|
98
|
+
"**Media Player Card** (music status):",
|
|
99
|
+
" [[media_player: Song Title | Artist Name | Source | https://albumart.url | playing]]",
|
|
100
|
+
" - Status: 'playing' or 'paused' (optional)",
|
|
101
|
+
"",
|
|
102
|
+
"**Event Card** (calendar events, meetings):",
|
|
103
|
+
" [[event: Event Title | Date | Time | Location | Description]]",
|
|
104
|
+
" - Time, Location, Description are optional",
|
|
105
|
+
"",
|
|
106
|
+
"**Agenda Card** (multiple events/schedule):",
|
|
107
|
+
" [[agenda: Schedule Title | Event1:9:00 AM, Event2:12:00 PM, Event3:3:00 PM]]",
|
|
108
|
+
"",
|
|
109
|
+
"**Device Control Card** (smart devices, TVs, etc.):",
|
|
110
|
+
" [[device: Device Name | Device Type | Status | Control1:data1, Control2:data2]]",
|
|
111
|
+
"",
|
|
112
|
+
"**Apple TV Remote** (full D-pad + transport):",
|
|
113
|
+
" [[appletv_remote: Apple TV | Playing]]",
|
|
114
|
+
"",
|
|
115
|
+
"**Auto-converted**: Markdown tables become Flex cards, code blocks become styled cards.",
|
|
116
|
+
"",
|
|
117
|
+
"When to use rich messages:",
|
|
118
|
+
"- Use [[quick_replies:...]] when offering 2-4 clear options",
|
|
119
|
+
"- Use [[confirm:...]] for yes/no decisions",
|
|
120
|
+
"- Use [[buttons:...]] for menus with actions/links",
|
|
121
|
+
"- Use [[location:...]] when sharing a place",
|
|
122
|
+
"- Use [[media_player:...]] when showing what's playing",
|
|
123
|
+
"- Use [[event:...]] for calendar event details",
|
|
124
|
+
"- Use [[agenda:...]] for a day's schedule or event list",
|
|
125
|
+
"- Use [[device:...]] for smart device status/controls",
|
|
126
|
+
"- Tables/code in your response auto-convert to visual cards",
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
pairing: {
|
|
131
|
+
text: {
|
|
132
|
+
idLabel: "lineUserId",
|
|
133
|
+
message: "Klaw: your access has been approved.",
|
|
134
|
+
normalizeAllowEntry: createPairingPrefixStripper(/^line:(?:user:)?/i),
|
|
135
|
+
notify: async ({ cfg, id, message }) => {
|
|
136
|
+
const account = (getLineRuntime().channel.line?.resolveLineAccount ?? resolveLineAccount)({
|
|
137
|
+
cfg,
|
|
138
|
+
});
|
|
139
|
+
if (!account.channelAccessToken) {
|
|
140
|
+
throw new Error("LINE channel access token not configured");
|
|
141
|
+
}
|
|
142
|
+
const pushMessageLine =
|
|
143
|
+
getLineRuntime().channel.line?.pushMessageLine ??
|
|
144
|
+
(await loadLineChannelRuntime()).pushMessageLine;
|
|
145
|
+
await pushMessageLine(id, message, {
|
|
146
|
+
cfg,
|
|
147
|
+
accountId: account.accountId,
|
|
148
|
+
channelAccessToken: account.channelAccessToken,
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
security: lineSecurityAdapter,
|
|
154
|
+
outbound: lineOutboundAdapter,
|
|
155
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createScopedChannelConfigAdapter } from "klaw/plugin-sdk/channel-config-helpers";
|
|
2
|
+
import {
|
|
3
|
+
listLineAccountIds,
|
|
4
|
+
resolveDefaultLineAccountId,
|
|
5
|
+
resolveLineAccount,
|
|
6
|
+
type ResolvedLineAccount,
|
|
7
|
+
} from "./channel-api.js";
|
|
8
|
+
|
|
9
|
+
function normalizeLineAllowFrom(entry: string): string {
|
|
10
|
+
return entry.replace(/^line:(?:user:)?/i, "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const lineConfigAdapter = createScopedChannelConfigAdapter<
|
|
14
|
+
ResolvedLineAccount,
|
|
15
|
+
ResolvedLineAccount
|
|
16
|
+
>({
|
|
17
|
+
sectionKey: "line",
|
|
18
|
+
listAccountIds: listLineAccountIds,
|
|
19
|
+
resolveAccount: (cfg, accountId) =>
|
|
20
|
+
resolveLineAccount({ cfg, accountId: accountId ?? undefined }),
|
|
21
|
+
defaultAccountId: resolveDefaultLineAccountId,
|
|
22
|
+
clearBaseFields: ["channelSecret", "tokenFile", "secretFile"],
|
|
23
|
+
resolveAllowFrom: (account) => account.config.allowFrom,
|
|
24
|
+
formatAllowFrom: (allowFrom) =>
|
|
25
|
+
allowFrom
|
|
26
|
+
.map((entry) => String(entry).trim())
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.map(normalizeLineAllowFrom),
|
|
29
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { LineConfigSchema } from "./config-schema.js";
|
|
3
|
+
|
|
4
|
+
describe("LineConfigSchema", () => {
|
|
5
|
+
it('rejects dmPolicy="open" without wildcard allowFrom', () => {
|
|
6
|
+
const result = LineConfigSchema.safeParse({
|
|
7
|
+
channelAccessToken: "token",
|
|
8
|
+
channelSecret: "secret",
|
|
9
|
+
dmPolicy: "open",
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
if (result.success) {
|
|
13
|
+
throw new Error("Expected config validation to fail");
|
|
14
|
+
}
|
|
15
|
+
expect(result.error.issues).toHaveLength(1);
|
|
16
|
+
expect(result.error.issues[0]?.path).toEqual(["allowFrom"]);
|
|
17
|
+
expect(result.error.issues[0]?.message).toBe(
|
|
18
|
+
'channels.line.dmPolicy="open" requires channels.line.allowFrom to include "*"',
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('accepts dmPolicy="open" with wildcard allowFrom', () => {
|
|
23
|
+
const result = LineConfigSchema.safeParse({
|
|
24
|
+
channelAccessToken: "token",
|
|
25
|
+
channelSecret: "secret",
|
|
26
|
+
dmPolicy: "open",
|
|
27
|
+
allowFrom: ["*"],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(result.success).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('rejects account dmPolicy="open" without wildcard allowFrom', () => {
|
|
34
|
+
const result = LineConfigSchema.safeParse({
|
|
35
|
+
accounts: {
|
|
36
|
+
work: {
|
|
37
|
+
channelAccessToken: "token",
|
|
38
|
+
channelSecret: "secret",
|
|
39
|
+
dmPolicy: "open",
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (result.success) {
|
|
45
|
+
throw new Error("Expected account config validation to fail");
|
|
46
|
+
}
|
|
47
|
+
expect(result.error.issues).toHaveLength(1);
|
|
48
|
+
expect(result.error.issues[0]?.path).toEqual(["accounts", "work", "allowFrom"]);
|
|
49
|
+
expect(result.error.issues[0]?.message).toBe(
|
|
50
|
+
'channels.line.dmPolicy="open" requires channels.line.allowFrom to include "*"',
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildChannelConfigSchema,
|
|
3
|
+
requireOpenAllowFrom,
|
|
4
|
+
} from "klaw/plugin-sdk/channel-config-schema";
|
|
5
|
+
import { requireChannelOpenAllowFrom } from "klaw/plugin-sdk/extension-shared";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
const DmPolicySchema = z.enum(["open", "allowlist", "pairing", "disabled"]);
|
|
9
|
+
const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
|
|
10
|
+
const ThreadBindingsSchema = z
|
|
11
|
+
.object({
|
|
12
|
+
enabled: z.boolean().optional(),
|
|
13
|
+
idleHours: z.number().optional(),
|
|
14
|
+
maxAgeHours: z.number().optional(),
|
|
15
|
+
spawnSessions: z.boolean().optional(),
|
|
16
|
+
defaultSpawnContext: z.enum(["isolated", "fork"]).optional(),
|
|
17
|
+
spawnSubagentSessions: z.boolean().optional(),
|
|
18
|
+
spawnAcpSessions: z.boolean().optional(),
|
|
19
|
+
})
|
|
20
|
+
.strict();
|
|
21
|
+
|
|
22
|
+
const LineCommonConfigSchemaBase = z.object({
|
|
23
|
+
enabled: z.boolean().optional(),
|
|
24
|
+
channelAccessToken: z.string().optional(),
|
|
25
|
+
channelSecret: z.string().optional(),
|
|
26
|
+
tokenFile: z.string().optional(),
|
|
27
|
+
secretFile: z.string().optional(),
|
|
28
|
+
name: z.string().optional(),
|
|
29
|
+
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
30
|
+
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
31
|
+
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
|
32
|
+
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
|
33
|
+
responsePrefix: z.string().optional(),
|
|
34
|
+
mediaMaxMb: z.number().optional(),
|
|
35
|
+
webhookPath: z.string().optional(),
|
|
36
|
+
threadBindings: ThreadBindingsSchema.optional(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const LineGroupConfigSchema = z
|
|
40
|
+
.object({
|
|
41
|
+
enabled: z.boolean().optional(),
|
|
42
|
+
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
43
|
+
requireMention: z.boolean().optional(),
|
|
44
|
+
systemPrompt: z.string().optional(),
|
|
45
|
+
skills: z.array(z.string()).optional(),
|
|
46
|
+
})
|
|
47
|
+
.strict();
|
|
48
|
+
|
|
49
|
+
const LineAccountConfigSchema = LineCommonConfigSchemaBase.extend({
|
|
50
|
+
groups: z.record(z.string(), LineGroupConfigSchema.optional()).optional(),
|
|
51
|
+
})
|
|
52
|
+
.strict()
|
|
53
|
+
.superRefine((value, ctx) => {
|
|
54
|
+
requireChannelOpenAllowFrom({
|
|
55
|
+
channel: "line",
|
|
56
|
+
policy: value.dmPolicy,
|
|
57
|
+
allowFrom: value.allowFrom,
|
|
58
|
+
ctx,
|
|
59
|
+
requireOpenAllowFrom,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export const LineConfigSchema = LineCommonConfigSchemaBase.extend({
|
|
64
|
+
accounts: z.record(z.string(), LineAccountConfigSchema.optional()).optional(),
|
|
65
|
+
defaultAccount: z.string().optional(),
|
|
66
|
+
groups: z.record(z.string(), LineGroupConfigSchema.optional()).optional(),
|
|
67
|
+
})
|
|
68
|
+
.strict()
|
|
69
|
+
.superRefine((value, ctx) => {
|
|
70
|
+
requireChannelOpenAllowFrom({
|
|
71
|
+
channel: "line",
|
|
72
|
+
policy: value.dmPolicy,
|
|
73
|
+
allowFrom: value.allowFrom,
|
|
74
|
+
ctx,
|
|
75
|
+
requireOpenAllowFrom,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
export const LineChannelConfigSchema = buildChannelConfigSchema(LineConfigSchema);
|
|
80
|
+
|
|
81
|
+
export type LineConfigSchemaType = z.infer<typeof LineConfigSchema>;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const getMessageContentMock = vi.hoisted(() => vi.fn());
|
|
4
|
+
const saveMediaStreamMock = vi.hoisted(() => vi.fn());
|
|
5
|
+
|
|
6
|
+
vi.mock("@line/bot-sdk", () => ({
|
|
7
|
+
messagingApi: {
|
|
8
|
+
MessagingApiBlobClient: class {
|
|
9
|
+
getMessageContent(messageId: string) {
|
|
10
|
+
return getMessageContentMock(messageId);
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock("klaw/plugin-sdk/runtime-env", () => ({
|
|
17
|
+
createSubsystemLogger: () => {
|
|
18
|
+
const logger = {
|
|
19
|
+
debug: () => {},
|
|
20
|
+
info: () => {},
|
|
21
|
+
warn: () => {},
|
|
22
|
+
error: () => {},
|
|
23
|
+
child: () => logger,
|
|
24
|
+
};
|
|
25
|
+
return logger;
|
|
26
|
+
},
|
|
27
|
+
logVerbose: () => {},
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
vi.mock("klaw/plugin-sdk/media-store", () => ({
|
|
31
|
+
saveMediaStream: saveMediaStreamMock,
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
let downloadLineMedia: typeof import("./download.js").downloadLineMedia;
|
|
35
|
+
|
|
36
|
+
async function* chunks(parts: Buffer[]): AsyncGenerator<Buffer> {
|
|
37
|
+
for (const part of parts) {
|
|
38
|
+
yield part;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function saveMediaStreamCall(): unknown[] {
|
|
43
|
+
const call = saveMediaStreamMock.mock.calls.at(0);
|
|
44
|
+
if (!call) {
|
|
45
|
+
throw new Error("Expected saveMediaStream call");
|
|
46
|
+
}
|
|
47
|
+
return call;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function detectMockContentType(buffer: Buffer, contentType?: string): string | undefined {
|
|
51
|
+
if (buffer[0] === 0xff && buffer[1] === 0xd8) {
|
|
52
|
+
return "image/jpeg";
|
|
53
|
+
}
|
|
54
|
+
if (buffer.toString("ascii", 4, 8) === "ftyp") {
|
|
55
|
+
return buffer.toString("ascii", 8, 12) === "M4A " ? "audio/x-m4a" : "video/mp4";
|
|
56
|
+
}
|
|
57
|
+
return contentType;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("downloadLineMedia", () => {
|
|
61
|
+
beforeAll(async () => {
|
|
62
|
+
({ downloadLineMedia } = await import("./download.js"));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterAll(() => {
|
|
66
|
+
vi.doUnmock("@line/bot-sdk");
|
|
67
|
+
vi.doUnmock("klaw/plugin-sdk/runtime-env");
|
|
68
|
+
vi.doUnmock("klaw/plugin-sdk/media-store");
|
|
69
|
+
vi.resetModules();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
vi.restoreAllMocks();
|
|
74
|
+
getMessageContentMock.mockReset();
|
|
75
|
+
saveMediaStreamMock.mockReset();
|
|
76
|
+
saveMediaStreamMock.mockImplementation(
|
|
77
|
+
async (stream: AsyncIterable<Buffer>, contentType?: string, subdir?: string) => {
|
|
78
|
+
const chunks: Buffer[] = [];
|
|
79
|
+
for await (const chunk of stream) {
|
|
80
|
+
chunks.push(Buffer.from(chunk));
|
|
81
|
+
}
|
|
82
|
+
const buffer = Buffer.concat(chunks);
|
|
83
|
+
return {
|
|
84
|
+
path: `/home/user/.klaw/media/${subdir ?? "unknown"}/saved-media`,
|
|
85
|
+
contentType: detectMockContentType(buffer, contentType),
|
|
86
|
+
size: buffer.length,
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("persists inbound media with the shared media store", async () => {
|
|
93
|
+
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
|
|
94
|
+
getMessageContentMock.mockResolvedValueOnce(chunks([jpeg]));
|
|
95
|
+
|
|
96
|
+
const result = await downloadLineMedia("mid-jpeg", "token");
|
|
97
|
+
|
|
98
|
+
expect(saveMediaStreamMock).toHaveBeenCalledTimes(1);
|
|
99
|
+
const call = saveMediaStreamCall();
|
|
100
|
+
expect(call[1]).toBeUndefined();
|
|
101
|
+
expect(call[2]).toBe("inbound");
|
|
102
|
+
expect(call[3]).toBe(10 * 1024 * 1024);
|
|
103
|
+
expect(result).toEqual({
|
|
104
|
+
path: "/home/user/.klaw/media/inbound/saved-media",
|
|
105
|
+
contentType: "image/jpeg",
|
|
106
|
+
size: jpeg.length,
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("does not pass the external messageId to saveMediaStream", async () => {
|
|
111
|
+
const messageId = "a/../../../../etc/passwd";
|
|
112
|
+
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
|
|
113
|
+
getMessageContentMock.mockResolvedValueOnce(chunks([jpeg]));
|
|
114
|
+
|
|
115
|
+
const result = await downloadLineMedia(messageId, "token");
|
|
116
|
+
|
|
117
|
+
expect(result.size).toBe(jpeg.length);
|
|
118
|
+
expect(result.contentType).toBe("image/jpeg");
|
|
119
|
+
for (const arg of saveMediaStreamCall()) {
|
|
120
|
+
if (typeof arg === "string") {
|
|
121
|
+
expect(arg).not.toContain(messageId);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("delegates oversized media rejection to saveMediaStream", async () => {
|
|
127
|
+
getMessageContentMock.mockResolvedValueOnce(chunks([Buffer.alloc(4), Buffer.alloc(4)]));
|
|
128
|
+
saveMediaStreamMock.mockRejectedValueOnce(new Error("Media exceeds 0MB limit"));
|
|
129
|
+
|
|
130
|
+
await expect(downloadLineMedia("mid", "token", 7)).rejects.toThrow(/Media exceeds/i);
|
|
131
|
+
expect(saveMediaStreamMock).toHaveBeenCalledTimes(1);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("uses media store content type for M4A media", async () => {
|
|
135
|
+
const m4aHeader = Buffer.from([
|
|
136
|
+
0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x4d, 0x34, 0x41, 0x20,
|
|
137
|
+
]);
|
|
138
|
+
getMessageContentMock.mockResolvedValueOnce(chunks([m4aHeader]));
|
|
139
|
+
|
|
140
|
+
const result = await downloadLineMedia("mid-audio", "token");
|
|
141
|
+
|
|
142
|
+
expect(result.contentType).toBe("audio/x-m4a");
|
|
143
|
+
expect(saveMediaStreamCall()[2]).toBe("inbound");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("uses media store content type for MP4 video", async () => {
|
|
147
|
+
const mp4 = Buffer.from([
|
|
148
|
+
0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d,
|
|
149
|
+
]);
|
|
150
|
+
getMessageContentMock.mockResolvedValueOnce(chunks([mp4]));
|
|
151
|
+
|
|
152
|
+
const result = await downloadLineMedia("mid-mp4", "token");
|
|
153
|
+
|
|
154
|
+
expect(result.contentType).toBe("video/mp4");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("propagates media store failures", async () => {
|
|
158
|
+
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
|
|
159
|
+
getMessageContentMock.mockResolvedValueOnce(chunks([jpeg]));
|
|
160
|
+
saveMediaStreamMock.mockRejectedValueOnce(new Error("Media exceeds 0MB limit"));
|
|
161
|
+
|
|
162
|
+
await expect(downloadLineMedia("mid-bad", "token")).rejects.toThrow(/Media exceeds/i);
|
|
163
|
+
});
|
|
164
|
+
});
|
package/src/download.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { messagingApi } from "@line/bot-sdk";
|
|
2
|
+
import { saveMediaStream } from "klaw/plugin-sdk/media-store";
|
|
3
|
+
import { logVerbose } from "klaw/plugin-sdk/runtime-env";
|
|
4
|
+
|
|
5
|
+
interface DownloadResult {
|
|
6
|
+
path: string;
|
|
7
|
+
contentType?: string;
|
|
8
|
+
size: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function downloadLineMedia(
|
|
12
|
+
messageId: string,
|
|
13
|
+
channelAccessToken: string,
|
|
14
|
+
maxBytes = 10 * 1024 * 1024,
|
|
15
|
+
): Promise<DownloadResult> {
|
|
16
|
+
const client = new messagingApi.MessagingApiBlobClient({
|
|
17
|
+
channelAccessToken,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const response = await client.getMessageContent(messageId);
|
|
21
|
+
const saved = await saveMediaStream(
|
|
22
|
+
response as AsyncIterable<Buffer>,
|
|
23
|
+
undefined,
|
|
24
|
+
"inbound",
|
|
25
|
+
maxBytes,
|
|
26
|
+
);
|
|
27
|
+
logVerbose(`line: persisted media ${messageId} to ${saved.path} (${saved.size} bytes)`);
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
path: saved.path,
|
|
31
|
+
contentType: saved.contentType,
|
|
32
|
+
size: saved.size,
|
|
33
|
+
};
|
|
34
|
+
}
|