openclaw-groupme 0.4.3 → 0.5.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.
- package/README.md +147 -45
- package/channel-plugin-api.ts +3 -0
- package/dist/channel-plugin-api.js +3 -0
- package/dist/index.js +15 -9
- package/dist/runtime-setter-api.js +1 -0
- package/dist/secret-contract-api.js +1 -0
- package/dist/setup-entry.js +16 -0
- package/dist/setup-plugin-api.js +3 -0
- package/dist/src/accounts.js +24 -48
- package/dist/src/channel.js +63 -29
- package/dist/src/config-schema.js +10 -11
- package/dist/src/groupme-api.js +9 -5
- package/dist/src/inbound.js +18 -10
- package/dist/src/monitor.js +25 -27
- package/dist/src/normalize.js +6 -0
- package/dist/src/onboarding.js +364 -337
- package/dist/src/parse.js +4 -14
- package/dist/src/policy.js +1 -1
- package/dist/src/rate-limit.js +12 -7
- package/dist/src/replay-cache.js +0 -3
- package/dist/src/secret-contract.js +49 -0
- package/dist/src/security.js +17 -34
- package/dist/src/send.js +19 -13
- package/index.ts +15 -10
- package/openclaw.plugin.json +14 -15
- package/package.json +43 -9
- package/runtime-setter-api.ts +1 -0
- package/secret-contract-api.ts +5 -0
- package/setup-entry.ts +17 -0
- package/setup-plugin-api.ts +3 -0
- package/src/accounts.ts +29 -68
- package/src/channel.ts +74 -64
- package/src/config-schema.ts +10 -11
- package/src/groupme-api.ts +21 -5
- package/src/history.ts +1 -1
- package/src/inbound.ts +45 -75
- package/src/monitor.ts +37 -52
- package/src/normalize.ts +7 -1
- package/src/onboarding.ts +449 -409
- package/src/parse.ts +6 -23
- package/src/policy.ts +1 -4
- package/src/rate-limit.ts +15 -12
- package/src/replay-cache.ts +1 -4
- package/src/runtime.ts +1 -1
- package/src/secret-contract.ts +66 -0
- package/src/security.ts +28 -66
- package/src/send.ts +32 -38
- package/src/types.ts +7 -7
package/src/inbound.ts
CHANGED
|
@@ -1,37 +1,25 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
} from "openclaw/plugin-sdk";
|
|
1
|
+
import { logInboundDrop } from "openclaw/plugin-sdk/channel-logging";
|
|
2
|
+
import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-mention-gating";
|
|
3
|
+
import { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-reply-options-runtime";
|
|
4
|
+
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-gating";
|
|
5
|
+
import type { HistoryEntry, OpenClawConfig, ReplyPayload } from "openclaw/plugin-sdk/core";
|
|
6
6
|
import {
|
|
7
7
|
buildPendingHistoryContextFromMap,
|
|
8
8
|
clearHistoryEntriesIfEnabled,
|
|
9
|
-
createReplyPrefixOptions,
|
|
10
|
-
logInboundDrop,
|
|
11
9
|
recordPendingHistoryEntryIfEnabled,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
type HistoryEntry,
|
|
15
|
-
} from "openclaw/plugin-sdk";
|
|
16
|
-
import type {
|
|
17
|
-
GroupMeCallbackData,
|
|
18
|
-
ResolvedGroupMeAccount,
|
|
19
|
-
CoreConfig,
|
|
20
|
-
} from "./types.js";
|
|
10
|
+
} from "openclaw/plugin-sdk/reply-history";
|
|
11
|
+
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
|
21
12
|
import {
|
|
22
13
|
buildGroupMeHistoryEntry,
|
|
23
14
|
formatGroupMeHistoryEntry,
|
|
24
15
|
resolveGroupMeBodyForAgent,
|
|
25
16
|
} from "./history.js";
|
|
26
|
-
import {
|
|
17
|
+
import { detectGroupMeMention, extractImageUrls } from "./parse.js";
|
|
27
18
|
import { resolveSenderAccess } from "./policy.js";
|
|
28
19
|
import { getGroupMeRuntime } from "./runtime.js";
|
|
29
|
-
import {
|
|
30
|
-
GROUPME_MAX_TEXT_LENGTH,
|
|
31
|
-
sendGroupMeMedia,
|
|
32
|
-
sendGroupMeText,
|
|
33
|
-
} from "./send.js";
|
|
34
20
|
import { resolveGroupMeSecurity } from "./security.js";
|
|
21
|
+
import { GROUPME_MAX_TEXT_LENGTH, sendGroupMeMedia, sendGroupMeText } from "./send.js";
|
|
22
|
+
import type { CoreConfig, GroupMeCallbackData, ResolvedGroupMeAccount } from "./types.js";
|
|
35
23
|
|
|
36
24
|
const CHANNEL_ID = "groupme" as const;
|
|
37
25
|
|
|
@@ -57,9 +45,7 @@ function chunkReplyText(params: {
|
|
|
57
45
|
return [];
|
|
58
46
|
}
|
|
59
47
|
|
|
60
|
-
return params.core.channel.text
|
|
61
|
-
.chunkMarkdownText(trimmed, params.limit)
|
|
62
|
-
.filter(Boolean);
|
|
48
|
+
return params.core.channel.text.chunkMarkdownText(trimmed, params.limit).filter(Boolean);
|
|
63
49
|
}
|
|
64
50
|
|
|
65
51
|
async function deliverGroupMeReply(params: {
|
|
@@ -156,20 +142,9 @@ export async function handleGroupMeInbound(params: {
|
|
|
156
142
|
runtime: RuntimeEnv;
|
|
157
143
|
groupHistories: Map<string, HistoryEntry[]>;
|
|
158
144
|
historyLimit: number;
|
|
159
|
-
statusSink?: (patch: {
|
|
160
|
-
lastInboundAt?: number;
|
|
161
|
-
lastOutboundAt?: number;
|
|
162
|
-
}) => void;
|
|
145
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
163
146
|
}): Promise<void> {
|
|
164
|
-
const {
|
|
165
|
-
message,
|
|
166
|
-
account,
|
|
167
|
-
config,
|
|
168
|
-
runtime,
|
|
169
|
-
groupHistories,
|
|
170
|
-
historyLimit,
|
|
171
|
-
statusSink,
|
|
172
|
-
} = params;
|
|
147
|
+
const { message, account, config, runtime, groupHistories, historyLimit, statusSink } = params;
|
|
173
148
|
const core = getGroupMeRuntime();
|
|
174
149
|
|
|
175
150
|
const inboundTimestamp = message.createdAt * 1000;
|
|
@@ -188,9 +163,7 @@ export async function handleGroupMeInbound(params: {
|
|
|
188
163
|
allowFrom,
|
|
189
164
|
});
|
|
190
165
|
if (!senderAllowed) {
|
|
191
|
-
runtime.log?.(
|
|
192
|
-
`groupme: drop sender ${message.senderId} (not in allowFrom)`,
|
|
193
|
-
);
|
|
166
|
+
runtime.log?.(`groupme: drop sender ${message.senderId} (not in allowFrom)`);
|
|
194
167
|
return;
|
|
195
168
|
}
|
|
196
169
|
|
|
@@ -224,8 +197,7 @@ export async function handleGroupMeInbound(params: {
|
|
|
224
197
|
message.text,
|
|
225
198
|
config as OpenClawConfig,
|
|
226
199
|
);
|
|
227
|
-
const commandBypassNeedsAllowFrom =
|
|
228
|
-
security.commandBypass.requireAllowFrom && hasControlCommand;
|
|
200
|
+
const commandBypassNeedsAllowFrom = security.commandBypass.requireAllowFrom && hasControlCommand;
|
|
229
201
|
const commandBypassCanSkipMention = !(
|
|
230
202
|
security.commandBypass.requireMentionForCommands &&
|
|
231
203
|
requireMention &&
|
|
@@ -233,8 +205,7 @@ export async function handleGroupMeInbound(params: {
|
|
|
233
205
|
);
|
|
234
206
|
|
|
235
207
|
const commandGate = resolveControlCommandGate({
|
|
236
|
-
useAccessGroups:
|
|
237
|
-
config.commands?.useAccessGroups !== false || commandBypassNeedsAllowFrom,
|
|
208
|
+
useAccessGroups: config.commands?.useAccessGroups !== false || commandBypassNeedsAllowFrom,
|
|
238
209
|
authorizers: [{ configured: allowFrom.length > 0, allowed: senderAllowed }],
|
|
239
210
|
allowTextCommands,
|
|
240
211
|
hasControlCommand,
|
|
@@ -257,9 +228,7 @@ export async function handleGroupMeInbound(params: {
|
|
|
257
228
|
hasAnyMention: false,
|
|
258
229
|
allowTextCommands,
|
|
259
230
|
hasControlCommand: commandBypassCanSkipMention ? hasControlCommand : false,
|
|
260
|
-
commandAuthorized: commandBypassCanSkipMention
|
|
261
|
-
? commandGate.commandAuthorized
|
|
262
|
-
: false,
|
|
231
|
+
commandAuthorized: commandBypassCanSkipMention ? commandGate.commandAuthorized : false,
|
|
263
232
|
});
|
|
264
233
|
|
|
265
234
|
const imageUrls = extractImageUrls(message.attachments);
|
|
@@ -291,15 +260,10 @@ export async function handleGroupMeInbound(params: {
|
|
|
291
260
|
return;
|
|
292
261
|
}
|
|
293
262
|
|
|
294
|
-
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
config.session?.store,
|
|
299
|
-
{
|
|
300
|
-
agentId: route.agentId,
|
|
301
|
-
},
|
|
302
|
-
);
|
|
263
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config as OpenClawConfig);
|
|
264
|
+
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
|
265
|
+
agentId: route.agentId,
|
|
266
|
+
});
|
|
303
267
|
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
304
268
|
storePath,
|
|
305
269
|
sessionKey: route.sessionKey,
|
|
@@ -313,6 +277,14 @@ export async function handleGroupMeInbound(params: {
|
|
|
313
277
|
envelope: envelopeOptions,
|
|
314
278
|
body: bodyForAgent,
|
|
315
279
|
});
|
|
280
|
+
// Snapshot-then-clear the per-group buffer. This runs synchronously before the
|
|
281
|
+
// first `await` below, so it is atomic with respect to other inbound handlers for
|
|
282
|
+
// the same group (handlers run un-awaited and concurrently up to maxConcurrent).
|
|
283
|
+
// Accepted behavior: if two mentions for the same group arrive nearly together,
|
|
284
|
+
// the first handler consumes the buffered context and the second sees an empty
|
|
285
|
+
// buffer rather than re-reading the same entries — buffered context is consumed
|
|
286
|
+
// exactly once, never duplicated. Messages buffered while a reply is in flight are
|
|
287
|
+
// preserved because the clear happens before dispatch.
|
|
316
288
|
const shouldUseHistoryBuffer = requireMention && historyLimit > 0;
|
|
317
289
|
const historyEntriesForContext = shouldUseHistoryBuffer
|
|
318
290
|
? [...(groupHistories.get(message.groupId) ?? [])]
|
|
@@ -325,24 +297,22 @@ export async function handleGroupMeInbound(params: {
|
|
|
325
297
|
});
|
|
326
298
|
}
|
|
327
299
|
|
|
328
|
-
const combinedBody =
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
}))
|
|
345
|
-
: undefined;
|
|
300
|
+
const combinedBody = shouldUseHistoryBuffer
|
|
301
|
+
? buildPendingHistoryContextFromMap({
|
|
302
|
+
historyMap: new Map([[message.groupId, historyEntriesForContext]]),
|
|
303
|
+
historyKey: message.groupId,
|
|
304
|
+
limit: historyLimit,
|
|
305
|
+
currentMessage: body,
|
|
306
|
+
formatEntry: formatGroupMeHistoryEntry,
|
|
307
|
+
})
|
|
308
|
+
: body;
|
|
309
|
+
const inboundHistory = shouldUseHistoryBuffer
|
|
310
|
+
? historyEntriesForContext.map((entry) => ({
|
|
311
|
+
sender: entry.sender,
|
|
312
|
+
body: entry.body,
|
|
313
|
+
timestamp: entry.timestamp,
|
|
314
|
+
}))
|
|
315
|
+
: undefined;
|
|
346
316
|
|
|
347
317
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
348
318
|
Body: combinedBody,
|
package/src/monitor.ts
CHANGED
|
@@ -1,54 +1,47 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
-
import type { HistoryEntry } from "openclaw/plugin-sdk";
|
|
3
|
-
import type { RuntimeEnv } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { HistoryEntry } from "openclaw/plugin-sdk/core";
|
|
3
|
+
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
|
4
4
|
import {
|
|
5
5
|
readJsonBodyWithLimit,
|
|
6
6
|
requestBodyErrorToText,
|
|
7
|
-
} from "openclaw/plugin-sdk";
|
|
7
|
+
} from "openclaw/plugin-sdk/webhook-request-guards";
|
|
8
8
|
import { resolveGroupMeHistoryLimit } from "./history.js";
|
|
9
9
|
import { handleGroupMeInbound } from "./inbound.js";
|
|
10
10
|
import { parseGroupMeCallback, shouldProcessCallback } from "./parse.js";
|
|
11
11
|
import { GroupMeRateLimiter } from "./rate-limit.js";
|
|
12
|
-
import {
|
|
12
|
+
import { buildReplayKey, GroupMeReplayCache } from "./replay-cache.js";
|
|
13
13
|
import {
|
|
14
14
|
checkGroupBinding,
|
|
15
|
-
|
|
15
|
+
type ResolvedGroupMeSecurity,
|
|
16
|
+
redactWebhookUrl,
|
|
16
17
|
resolveGroupMeSecurity,
|
|
17
18
|
validateProxyRequest,
|
|
18
|
-
type ResolvedGroupMeSecurity,
|
|
19
19
|
verifyCallbackAuth,
|
|
20
20
|
} from "./security.js";
|
|
21
|
-
import type {
|
|
22
|
-
CoreConfig,
|
|
23
|
-
ResolvedGroupMeAccount,
|
|
24
|
-
WebhookDecision,
|
|
25
|
-
} from "./types.js";
|
|
21
|
+
import type { CoreConfig, ResolvedGroupMeAccount, WebhookDecision } from "./types.js";
|
|
26
22
|
|
|
27
23
|
// GroupMe callbacks are small JSON payloads; use tighter limits than the SDK
|
|
28
24
|
// defaults (DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1 MB, DEFAULT_WEBHOOK_BODY_TIMEOUT_MS = 30 s).
|
|
29
25
|
const GROUPME_WEBHOOK_MAX_BODY_BYTES = 64 * 1024;
|
|
30
26
|
const GROUPME_WEBHOOK_BODY_TIMEOUT_MS = 15_000;
|
|
31
27
|
|
|
32
|
-
|
|
28
|
+
type GroupMeWebhookHandlerParams = {
|
|
33
29
|
account: ResolvedGroupMeAccount;
|
|
34
30
|
config: CoreConfig;
|
|
35
31
|
runtime: RuntimeEnv;
|
|
36
|
-
statusSink?: (patch: {
|
|
37
|
-
lastInboundAt?: number;
|
|
38
|
-
lastOutboundAt?: number;
|
|
39
|
-
}) => void;
|
|
32
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
40
33
|
};
|
|
41
34
|
|
|
42
35
|
function rejectDecision(params: {
|
|
43
36
|
status: number;
|
|
44
37
|
reason: string;
|
|
45
|
-
logLevel
|
|
38
|
+
logLevel: "debug" | "warn";
|
|
46
39
|
}): WebhookDecision {
|
|
47
40
|
return {
|
|
48
41
|
kind: "reject",
|
|
49
42
|
status: params.status,
|
|
50
43
|
reason: params.reason,
|
|
51
|
-
logLevel: params.logLevel
|
|
44
|
+
logLevel: params.logLevel,
|
|
52
45
|
};
|
|
53
46
|
}
|
|
54
47
|
|
|
@@ -77,6 +70,9 @@ function asRequestBodyErrorCode(
|
|
|
77
70
|
) {
|
|
78
71
|
return value;
|
|
79
72
|
}
|
|
73
|
+
// Only ever called with the three codes above (INVALID_JSON returns earlier in
|
|
74
|
+
// the handler), so this null path is unreachable defense-in-depth.
|
|
75
|
+
/* v8 ignore next */
|
|
80
76
|
return null;
|
|
81
77
|
}
|
|
82
78
|
|
|
@@ -90,10 +86,7 @@ function logWebhookRejection(params: {
|
|
|
90
86
|
return;
|
|
91
87
|
}
|
|
92
88
|
const url = params.security.logging.redactSecrets
|
|
93
|
-
?
|
|
94
|
-
`${params.reqUrl.pathname}${params.reqUrl.search}`,
|
|
95
|
-
params.security,
|
|
96
|
-
)
|
|
89
|
+
? redactWebhookUrl(`${params.reqUrl.pathname}${params.reqUrl.search}`, params.security)
|
|
97
90
|
: `${params.reqUrl.pathname}${params.reqUrl.search}`;
|
|
98
91
|
const line = `groupme: webhook rejected (${params.decision.reason}) status=${params.decision.status} url=${url}`;
|
|
99
92
|
if (params.decision.logLevel === "warn") {
|
|
@@ -148,14 +141,13 @@ async function decideWebhookRequest(params: {
|
|
|
148
141
|
emptyObjectOnEmpty: false,
|
|
149
142
|
});
|
|
150
143
|
if (!body.ok) {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
}
|
|
144
|
+
// Map the SDK body-error code to an HTTP status; unmapped codes (INVALID_JSON,
|
|
145
|
+
// CONNECTION_CLOSED, …) fall back to 400.
|
|
146
|
+
const bodyErrorStatus: Record<string, number | undefined> = {
|
|
147
|
+
PAYLOAD_TOO_LARGE: 413,
|
|
148
|
+
REQUEST_BODY_TIMEOUT: 408,
|
|
149
|
+
};
|
|
150
|
+
const status = bodyErrorStatus[body.code] ?? 400;
|
|
159
151
|
return rejectDecision({
|
|
160
152
|
status,
|
|
161
153
|
reason: `body_${body.code.toLowerCase()}`,
|
|
@@ -193,19 +185,13 @@ async function decideWebhookRequest(params: {
|
|
|
193
185
|
});
|
|
194
186
|
}
|
|
195
187
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
});
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
if (!params.security.rateLimit.enabled) {
|
|
208
|
-
return { kind: "accept", message, release: () => undefined };
|
|
188
|
+
const replay = params.replayCache.checkAndRemember(buildReplayKey(message));
|
|
189
|
+
if (replay.kind === "duplicate") {
|
|
190
|
+
return rejectDecision({
|
|
191
|
+
status: 200,
|
|
192
|
+
reason: "duplicate_replay",
|
|
193
|
+
logLevel: "debug",
|
|
194
|
+
});
|
|
209
195
|
}
|
|
210
196
|
|
|
211
197
|
const rate = params.rateLimiter.evaluate({
|
|
@@ -227,15 +213,18 @@ export function createGroupMeWebhookHandler(
|
|
|
227
213
|
params: GroupMeWebhookHandlerParams,
|
|
228
214
|
): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
|
229
215
|
const groupHistories = new Map<string, HistoryEntry[]>();
|
|
230
|
-
const historyLimit = resolveGroupMeHistoryLimit(
|
|
231
|
-
params.account.config.historyLimit,
|
|
232
|
-
);
|
|
216
|
+
const historyLimit = resolveGroupMeHistoryLimit(params.account.config.historyLimit);
|
|
233
217
|
const security = resolveGroupMeSecurity(params.account.config);
|
|
234
218
|
if (!security.groupId) {
|
|
235
219
|
params.runtime.error?.(
|
|
236
220
|
"groupme: WARNING — no groupId configured; all inbound messages will be rejected. Set groupId in your account config.",
|
|
237
221
|
);
|
|
238
222
|
}
|
|
223
|
+
if (!security.callbackToken) {
|
|
224
|
+
params.runtime.error?.(
|
|
225
|
+
"groupme: WARNING — no callbackToken configured; inbound callbacks are not token-authenticated. Anyone who learns the webhook path and group_id can post. Set callbackToken in your account config.",
|
|
226
|
+
);
|
|
227
|
+
}
|
|
239
228
|
const replayCache = new GroupMeReplayCache({
|
|
240
229
|
ttlSeconds: security.replay.ttlSeconds,
|
|
241
230
|
maxEntries: security.replay.maxEntries,
|
|
@@ -280,9 +269,7 @@ export function createGroupMeWebhookHandler(
|
|
|
280
269
|
}
|
|
281
270
|
const requestBodyErrorCode = asRequestBodyErrorCode(code);
|
|
282
271
|
if (requestBodyErrorCode) {
|
|
283
|
-
res.end(
|
|
284
|
-
requestBodyErrorToText(requestBodyErrorCode),
|
|
285
|
-
);
|
|
272
|
+
res.end(requestBodyErrorToText(requestBodyErrorCode));
|
|
286
273
|
return;
|
|
287
274
|
}
|
|
288
275
|
}
|
|
@@ -304,9 +291,7 @@ export function createGroupMeWebhookHandler(
|
|
|
304
291
|
historyLimit,
|
|
305
292
|
})
|
|
306
293
|
.catch((err) => {
|
|
307
|
-
params.runtime.error?.(
|
|
308
|
-
`groupme: inbound processing failed: ${String(err)}`,
|
|
309
|
-
);
|
|
294
|
+
params.runtime.error?.(`groupme: inbound processing failed: ${String(err)}`);
|
|
310
295
|
})
|
|
311
296
|
.finally(() => {
|
|
312
297
|
release();
|
package/src/normalize.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
export function normalizeStringId(raw:
|
|
1
|
+
export function normalizeStringId(raw: unknown): string | undefined {
|
|
2
|
+
if (typeof raw !== "string" && typeof raw !== "number") {
|
|
3
|
+
return undefined;
|
|
4
|
+
}
|
|
5
|
+
if (typeof raw === "number" && !Number.isFinite(raw)) {
|
|
6
|
+
return undefined;
|
|
7
|
+
}
|
|
2
8
|
const normalized = String(raw).trim();
|
|
3
9
|
return normalized || undefined;
|
|
4
10
|
}
|