@openclaw/feishu 2026.3.12 → 2026.5.1-beta.1
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 +31 -0
- package/channel-entry.ts +20 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +16 -0
- package/index.ts +70 -53
- package/openclaw.plugin.json +1653 -4
- package/package.json +32 -7
- package/runtime-api.ts +55 -0
- package/secret-contract-api.ts +5 -0
- package/security-contract-api.ts +1 -0
- package/session-key-api.ts +1 -0
- package/setup-api.ts +3 -0
- package/setup-entry.test.ts +14 -0
- package/setup-entry.ts +13 -0
- package/src/accounts.test.ts +115 -22
- package/src/accounts.ts +199 -117
- package/src/app-registration.ts +331 -0
- package/src/approval-auth.test.ts +24 -0
- package/src/approval-auth.ts +25 -0
- package/src/async.test.ts +35 -0
- package/src/async.ts +43 -1
- package/src/audio-preflight.runtime.ts +9 -0
- package/src/bitable.test.ts +131 -0
- package/src/bitable.ts +59 -22
- package/src/bot-content.ts +474 -0
- package/src/bot-group-name.test.ts +108 -0
- package/src/bot-runtime-api.ts +12 -0
- package/src/bot-sender-name.ts +125 -0
- package/src/bot.broadcast.test.ts +463 -0
- package/src/bot.card-action.test.ts +519 -5
- package/src/bot.checkBotMentioned.test.ts +92 -20
- package/src/bot.helpers.test.ts +118 -0
- package/src/bot.stripBotMention.test.ts +13 -21
- package/src/bot.test.ts +1334 -401
- package/src/bot.ts +798 -786
- package/src/card-action.ts +408 -40
- package/src/card-interaction.test.ts +129 -0
- package/src/card-interaction.ts +159 -0
- package/src/card-test-helpers.ts +47 -0
- package/src/card-ux-approval.ts +65 -0
- package/src/card-ux-launcher.test.ts +99 -0
- package/src/card-ux-launcher.ts +121 -0
- package/src/card-ux-shared.ts +33 -0
- package/src/channel-runtime-api.ts +16 -0
- package/src/channel.runtime.ts +47 -0
- package/src/channel.test.ts +914 -3
- package/src/channel.ts +1252 -309
- package/src/chat-schema.ts +5 -4
- package/src/chat.test.ts +84 -28
- package/src/chat.ts +68 -10
- package/src/client.test.ts +212 -103
- package/src/client.ts +115 -21
- package/src/comment-dispatcher-runtime-api.ts +6 -0
- package/src/comment-dispatcher.test.ts +169 -0
- package/src/comment-dispatcher.ts +107 -0
- package/src/comment-handler-runtime-api.ts +3 -0
- package/src/comment-handler.test.ts +486 -0
- package/src/comment-handler.ts +309 -0
- package/src/comment-reaction.test.ts +166 -0
- package/src/comment-reaction.ts +259 -0
- package/src/comment-shared.test.ts +182 -0
- package/src/comment-shared.ts +365 -0
- package/src/comment-target.ts +44 -0
- package/src/config-schema.test.ts +77 -25
- package/src/config-schema.ts +31 -4
- package/src/conversation-id.test.ts +18 -0
- package/src/conversation-id.ts +199 -0
- package/src/dedup-runtime-api.ts +1 -0
- package/src/dedup.ts +76 -35
- package/src/directory.static.ts +61 -0
- package/src/directory.test.ts +119 -20
- package/src/directory.ts +61 -91
- package/src/doc-schema.ts +1 -1
- package/src/docx-batch-insert.test.ts +39 -38
- package/src/docx-batch-insert.ts +55 -19
- package/src/docx-color-text.ts +9 -4
- package/src/docx-table-ops.test.ts +53 -0
- package/src/docx-table-ops.ts +52 -34
- package/src/docx-types.ts +38 -0
- package/src/docx.account-selection.test.ts +12 -3
- package/src/docx.test.ts +314 -74
- package/src/docx.ts +278 -122
- package/src/drive-schema.ts +47 -1
- package/src/drive.test.ts +1219 -0
- package/src/drive.ts +614 -13
- package/src/dynamic-agent.ts +10 -4
- package/src/event-types.ts +45 -0
- package/src/external-keys.ts +1 -1
- package/src/lifecycle.test-support.ts +220 -0
- package/src/media.test.ts +413 -87
- package/src/media.ts +488 -154
- package/src/mention-target.types.ts +5 -0
- package/src/mention.ts +32 -51
- package/src/message-action-contract.ts +13 -0
- package/src/monitor-state-runtime-api.ts +7 -0
- package/src/monitor-transport-runtime-api.ts +7 -0
- package/src/monitor.account.ts +220 -313
- package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
- package/src/monitor.bot-identity.ts +86 -0
- package/src/monitor.bot-menu-handler.ts +165 -0
- package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
- package/src/monitor.bot-menu.test.ts +178 -0
- package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
- package/src/monitor.card-action.lifecycle.test-support.ts +373 -0
- package/src/monitor.cleanup.test.ts +376 -0
- package/src/monitor.comment-notice-handler.ts +105 -0
- package/src/monitor.comment.test.ts +937 -0
- package/src/monitor.comment.ts +1386 -0
- package/src/monitor.lifecycle.test.ts +4 -0
- package/src/monitor.message-handler.ts +339 -0
- package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
- package/src/monitor.reaction.test.ts +194 -92
- package/src/monitor.reply-once.lifecycle.test-support.ts +190 -0
- package/src/monitor.startup.test.ts +24 -36
- package/src/monitor.startup.ts +26 -16
- package/src/monitor.state.ts +20 -5
- package/src/monitor.synthetic-error.ts +18 -0
- package/src/monitor.test-mocks.ts +2 -2
- package/src/monitor.transport.ts +297 -39
- package/src/monitor.ts +15 -10
- package/src/monitor.webhook-e2e.test.ts +272 -0
- package/src/monitor.webhook-security.test.ts +125 -91
- package/src/monitor.webhook.test-helpers.ts +116 -0
- package/src/outbound-runtime-api.ts +1 -0
- package/src/outbound.test.ts +627 -53
- package/src/outbound.ts +623 -81
- package/src/perm-schema.ts +1 -1
- package/src/perm.ts +1 -7
- package/src/pins.ts +108 -0
- package/src/policy.test.ts +297 -117
- package/src/policy.ts +142 -29
- package/src/post.ts +7 -6
- package/src/probe.test.ts +122 -118
- package/src/probe.ts +26 -16
- package/src/processing-claims.ts +59 -0
- package/src/qr-terminal.ts +1 -0
- package/src/reactions.ts +23 -60
- package/src/reasoning-preview.test.ts +59 -0
- package/src/reasoning-preview.ts +20 -0
- package/src/reply-dispatcher-runtime-api.ts +7 -0
- package/src/reply-dispatcher.test.ts +721 -168
- package/src/reply-dispatcher.ts +422 -172
- package/src/runtime.ts +6 -3
- package/src/secret-contract.ts +145 -0
- package/src/secret-input.ts +1 -13
- package/src/security-audit-shared.ts +69 -0
- package/src/security-audit.test.ts +61 -0
- package/src/security-audit.ts +1 -0
- package/src/send-result.ts +1 -1
- package/src/send-target.test.ts +9 -3
- package/src/send-target.ts +10 -4
- package/src/send.reply-fallback.test.ts +127 -42
- package/src/send.test.ts +386 -4
- package/src/send.ts +486 -164
- package/src/sequential-key.test.ts +72 -0
- package/src/sequential-key.ts +28 -0
- package/src/sequential-queue.test.ts +92 -0
- package/src/sequential-queue.ts +16 -0
- package/src/session-conversation.ts +42 -0
- package/src/session-route.ts +48 -0
- package/src/setup-core.ts +51 -0
- package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
- package/src/setup-surface.ts +581 -0
- package/src/streaming-card.test.ts +138 -2
- package/src/streaming-card.ts +134 -18
- package/src/subagent-hooks.test.ts +603 -0
- package/src/subagent-hooks.ts +397 -0
- package/src/targets.ts +3 -13
- package/src/test-support/lifecycle-test-support.ts +479 -0
- package/src/thread-bindings.test.ts +143 -0
- package/src/thread-bindings.ts +330 -0
- package/src/tool-account-routing.test.ts +66 -8
- package/src/tool-account.test.ts +44 -0
- package/src/tool-account.ts +40 -17
- package/src/tool-factory-test-harness.ts +11 -8
- package/src/tool-result.ts +3 -1
- package/src/tools-config.ts +1 -1
- package/src/types.ts +16 -15
- package/src/typing.ts +10 -6
- package/src/wiki-schema.ts +1 -1
- package/src/wiki.ts +1 -7
- package/subagent-hooks-api.ts +31 -0
- package/tsconfig.json +16 -0
- package/src/feishu-command-handler.ts +0 -59
- package/src/onboarding.status.test.ts +0 -25
- package/src/onboarding.ts +0 -489
- package/src/send-message.ts +0 -71
- package/src/targets.test.ts +0 -70
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const EVENT_DEDUP_TTL_MS = 5 * 60 * 1000;
|
|
2
|
+
const EVENT_MEMORY_MAX_SIZE = 2_000;
|
|
3
|
+
|
|
4
|
+
const processingClaims = new Map<string, number>();
|
|
5
|
+
|
|
6
|
+
function resolveEventDedupeKey(
|
|
7
|
+
namespace: string,
|
|
8
|
+
messageId: string | undefined | null,
|
|
9
|
+
): string | null {
|
|
10
|
+
const trimmed = messageId?.trim();
|
|
11
|
+
return trimmed ? `${namespace}:${trimmed}` : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function pruneProcessingClaims(now: number): void {
|
|
15
|
+
const cutoff = now - EVENT_DEDUP_TTL_MS;
|
|
16
|
+
for (const [key, seenAt] of processingClaims) {
|
|
17
|
+
if (seenAt < cutoff) {
|
|
18
|
+
processingClaims.delete(key);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
while (processingClaims.size > EVENT_MEMORY_MAX_SIZE) {
|
|
22
|
+
const oldestKey = processingClaims.keys().next().value;
|
|
23
|
+
if (!oldestKey) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
processingClaims.delete(oldestKey);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function tryBeginFeishuMessageProcessing(
|
|
31
|
+
messageId: string | undefined | null,
|
|
32
|
+
namespace = "global",
|
|
33
|
+
): boolean {
|
|
34
|
+
const key = resolveEventDedupeKey(namespace, messageId);
|
|
35
|
+
if (!key) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
pruneProcessingClaims(now);
|
|
40
|
+
if (processingClaims.has(key)) {
|
|
41
|
+
processingClaims.delete(key);
|
|
42
|
+
processingClaims.set(key, now);
|
|
43
|
+
pruneProcessingClaims(now);
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
processingClaims.set(key, now);
|
|
47
|
+
pruneProcessingClaims(now);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function releaseFeishuMessageProcessing(
|
|
52
|
+
messageId: string | undefined | null,
|
|
53
|
+
namespace = "global",
|
|
54
|
+
): void {
|
|
55
|
+
const key = resolveEventDedupeKey(namespace, messageId);
|
|
56
|
+
if (key) {
|
|
57
|
+
processingClaims.delete(key);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { renderQrTerminal } from "openclaw/plugin-sdk/media-runtime";
|
package/src/reactions.ts
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
1
|
-
import type { ClawdbotConfig } from "
|
|
2
|
-
import {
|
|
1
|
+
import type { ClawdbotConfig } from "../runtime-api.js";
|
|
2
|
+
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
|
3
3
|
import { createFeishuClient } from "./client.js";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
type FeishuReaction = {
|
|
6
6
|
reactionId: string;
|
|
7
7
|
emojiType: string;
|
|
8
8
|
operatorType: "app" | "user";
|
|
9
9
|
operatorId: string;
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
+
function resolveConfiguredFeishuClient(params: { cfg: ClawdbotConfig; accountId?: string }) {
|
|
13
|
+
const account = resolveFeishuRuntimeAccount(params);
|
|
14
|
+
if (!account.configured) {
|
|
15
|
+
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
16
|
+
}
|
|
17
|
+
return createFeishuClient(account);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function assertFeishuReactionApiSuccess(response: { code?: number; msg?: string }, action: string) {
|
|
21
|
+
if (response.code !== 0) {
|
|
22
|
+
throw new Error(`Feishu ${action} failed: ${response.msg || `code ${response.code}`}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
12
26
|
/**
|
|
13
27
|
* Add a reaction (emoji) to a message.
|
|
14
28
|
* @param emojiType - Feishu emoji type, e.g., "SMILE", "THUMBSUP", "HEART"
|
|
@@ -21,12 +35,7 @@ export async function addReactionFeishu(params: {
|
|
|
21
35
|
accountId?: string;
|
|
22
36
|
}): Promise<{ reactionId: string }> {
|
|
23
37
|
const { cfg, messageId, emojiType, accountId } = params;
|
|
24
|
-
const
|
|
25
|
-
if (!account.configured) {
|
|
26
|
-
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const client = createFeishuClient(account);
|
|
38
|
+
const client = resolveConfiguredFeishuClient({ cfg, accountId });
|
|
30
39
|
|
|
31
40
|
const response = (await client.im.messageReaction.create({
|
|
32
41
|
path: { message_id: messageId },
|
|
@@ -41,9 +50,7 @@ export async function addReactionFeishu(params: {
|
|
|
41
50
|
data?: { reaction_id?: string };
|
|
42
51
|
};
|
|
43
52
|
|
|
44
|
-
|
|
45
|
-
throw new Error(`Feishu add reaction failed: ${response.msg || `code ${response.code}`}`);
|
|
46
|
-
}
|
|
53
|
+
assertFeishuReactionApiSuccess(response, "add reaction");
|
|
47
54
|
|
|
48
55
|
const reactionId = response.data?.reaction_id;
|
|
49
56
|
if (!reactionId) {
|
|
@@ -63,12 +70,7 @@ export async function removeReactionFeishu(params: {
|
|
|
63
70
|
accountId?: string;
|
|
64
71
|
}): Promise<void> {
|
|
65
72
|
const { cfg, messageId, reactionId, accountId } = params;
|
|
66
|
-
const
|
|
67
|
-
if (!account.configured) {
|
|
68
|
-
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const client = createFeishuClient(account);
|
|
73
|
+
const client = resolveConfiguredFeishuClient({ cfg, accountId });
|
|
72
74
|
|
|
73
75
|
const response = (await client.im.messageReaction.delete({
|
|
74
76
|
path: {
|
|
@@ -77,9 +79,7 @@ export async function removeReactionFeishu(params: {
|
|
|
77
79
|
},
|
|
78
80
|
})) as { code?: number; msg?: string };
|
|
79
81
|
|
|
80
|
-
|
|
81
|
-
throw new Error(`Feishu remove reaction failed: ${response.msg || `code ${response.code}`}`);
|
|
82
|
-
}
|
|
82
|
+
assertFeishuReactionApiSuccess(response, "remove reaction");
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
/**
|
|
@@ -92,12 +92,7 @@ export async function listReactionsFeishu(params: {
|
|
|
92
92
|
accountId?: string;
|
|
93
93
|
}): Promise<FeishuReaction[]> {
|
|
94
94
|
const { cfg, messageId, emojiType, accountId } = params;
|
|
95
|
-
const
|
|
96
|
-
if (!account.configured) {
|
|
97
|
-
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const client = createFeishuClient(account);
|
|
95
|
+
const client = resolveConfiguredFeishuClient({ cfg, accountId });
|
|
101
96
|
|
|
102
97
|
const response = (await client.im.messageReaction.list({
|
|
103
98
|
path: { message_id: messageId },
|
|
@@ -115,9 +110,7 @@ export async function listReactionsFeishu(params: {
|
|
|
115
110
|
};
|
|
116
111
|
};
|
|
117
112
|
|
|
118
|
-
|
|
119
|
-
throw new Error(`Feishu list reactions failed: ${response.msg || `code ${response.code}`}`);
|
|
120
|
-
}
|
|
113
|
+
assertFeishuReactionApiSuccess(response, "list reactions");
|
|
121
114
|
|
|
122
115
|
const items = response.data?.items ?? [];
|
|
123
116
|
return items.map((item) => ({
|
|
@@ -128,33 +121,3 @@ export async function listReactionsFeishu(params: {
|
|
|
128
121
|
item.operator_id?.open_id ?? item.operator_id?.user_id ?? item.operator_id?.union_id ?? "",
|
|
129
122
|
}));
|
|
130
123
|
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Common Feishu emoji types for convenience.
|
|
134
|
-
* @see https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce
|
|
135
|
-
*/
|
|
136
|
-
export const FeishuEmoji = {
|
|
137
|
-
// Common reactions
|
|
138
|
-
THUMBSUP: "THUMBSUP",
|
|
139
|
-
THUMBSDOWN: "THUMBSDOWN",
|
|
140
|
-
HEART: "HEART",
|
|
141
|
-
SMILE: "SMILE",
|
|
142
|
-
GRINNING: "GRINNING",
|
|
143
|
-
LAUGHING: "LAUGHING",
|
|
144
|
-
CRY: "CRY",
|
|
145
|
-
ANGRY: "ANGRY",
|
|
146
|
-
SURPRISED: "SURPRISED",
|
|
147
|
-
THINKING: "THINKING",
|
|
148
|
-
CLAP: "CLAP",
|
|
149
|
-
OK: "OK",
|
|
150
|
-
FIST: "FIST",
|
|
151
|
-
PRAY: "PRAY",
|
|
152
|
-
FIRE: "FIRE",
|
|
153
|
-
PARTY: "PARTY",
|
|
154
|
-
CHECK: "CHECK",
|
|
155
|
-
CROSS: "CROSS",
|
|
156
|
-
QUESTION: "QUESTION",
|
|
157
|
-
EXCLAMATION: "EXCLAMATION",
|
|
158
|
-
} as const;
|
|
159
|
-
|
|
160
|
-
export type FeishuEmojiType = (typeof FeishuEmoji)[keyof typeof FeishuEmoji];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { resolveFeishuReasoningPreviewEnabled } from "./reasoning-preview.js";
|
|
3
|
+
|
|
4
|
+
const { loadSessionStoreMock } = vi.hoisted(() => ({
|
|
5
|
+
loadSessionStoreMock: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock("./bot-runtime-api.js", async () => {
|
|
9
|
+
const actual =
|
|
10
|
+
await vi.importActual<typeof import("./bot-runtime-api.js")>("./bot-runtime-api.js");
|
|
11
|
+
return {
|
|
12
|
+
...actual,
|
|
13
|
+
loadSessionStore: loadSessionStoreMock,
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("resolveFeishuReasoningPreviewEnabled", () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.clearAllMocks();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("enables previews only for stream reasoning sessions", () => {
|
|
23
|
+
loadSessionStoreMock.mockReturnValue({
|
|
24
|
+
"agent:main:feishu:dm:ou_sender_1": { reasoningLevel: "stream" },
|
|
25
|
+
"agent:main:feishu:dm:ou_sender_2": { reasoningLevel: "on" },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(
|
|
29
|
+
resolveFeishuReasoningPreviewEnabled({
|
|
30
|
+
storePath: "/tmp/feishu-sessions.json",
|
|
31
|
+
sessionKey: "agent:main:feishu:dm:ou_sender_1",
|
|
32
|
+
}),
|
|
33
|
+
).toBe(true);
|
|
34
|
+
expect(
|
|
35
|
+
resolveFeishuReasoningPreviewEnabled({
|
|
36
|
+
storePath: "/tmp/feishu-sessions.json",
|
|
37
|
+
sessionKey: "agent:main:feishu:dm:ou_sender_2",
|
|
38
|
+
}),
|
|
39
|
+
).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns false for missing sessions or load failures", () => {
|
|
43
|
+
loadSessionStoreMock.mockImplementationOnce(() => {
|
|
44
|
+
throw new Error("disk unavailable");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(
|
|
48
|
+
resolveFeishuReasoningPreviewEnabled({
|
|
49
|
+
storePath: "/tmp/feishu-sessions.json",
|
|
50
|
+
sessionKey: "agent:main:feishu:dm:ou_sender_1",
|
|
51
|
+
}),
|
|
52
|
+
).toBe(false);
|
|
53
|
+
expect(
|
|
54
|
+
resolveFeishuReasoningPreviewEnabled({
|
|
55
|
+
storePath: "/tmp/feishu-sessions.json",
|
|
56
|
+
}),
|
|
57
|
+
).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { loadSessionStore, resolveSessionStoreEntry } from "./bot-runtime-api.js";
|
|
2
|
+
|
|
3
|
+
export function resolveFeishuReasoningPreviewEnabled(params: {
|
|
4
|
+
storePath: string;
|
|
5
|
+
sessionKey?: string;
|
|
6
|
+
}): boolean {
|
|
7
|
+
if (!params.sessionKey) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const store = loadSessionStore(params.storePath, { skipCache: true });
|
|
13
|
+
return (
|
|
14
|
+
resolveSessionStoreEntry({ store, sessionKey: params.sessionKey }).existing
|
|
15
|
+
?.reasoningLevel === "stream"
|
|
16
|
+
);
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|