@shenhh/popo-native 0.1.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/index.ts ADDED
@@ -0,0 +1,53 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
3
+ import { popoNativePlugin } from "./src/channel.js";
4
+ import { setPopoNativeRuntime } from "./src/runtime.js";
5
+
6
+ export { monitorPopoNativeProvider } from "./src/monitor.js";
7
+ export {
8
+ sendMessagePopoNative,
9
+ sendCardPopoNative,
10
+ createStreamCardPopoNative,
11
+ updateStreamCardPopoNative,
12
+ updateInstructionVariableOptions,
13
+ } from "./src/send.js";
14
+ export {
15
+ uploadImagePopoNative,
16
+ sendImagePopoNative,
17
+ sendFilePopoNative,
18
+ sendMediaPopoNative,
19
+ recallMessagePopoNative,
20
+ getMessageReadAckPopoNative,
21
+ configureCardCallbackPopoNative,
22
+ downloadMessageFilePopoNative,
23
+ registerFileUploadPopoNative,
24
+ } from "./src/media.js";
25
+ export { probePopoNative } from "./src/probe.js";
26
+ export { popoNativePlugin } from "./src/channel.js";
27
+ export {
28
+ createTeam,
29
+ inviteToTeam,
30
+ dropTeam,
31
+ getTeamMembers,
32
+ getTeamInfo,
33
+ updateTeamInfo,
34
+ updateTeamManagement,
35
+ } from "./src/team.js";
36
+ export {
37
+ configureSubscription,
38
+ removeSubscription,
39
+ listSubscriptions,
40
+ } from "./src/subscription.js";
41
+
42
+ const plugin = {
43
+ id: "popo-native",
44
+ name: "POPO Native",
45
+ description: "POPO Native API channel plugin",
46
+ configSchema: emptyPluginConfigSchema(),
47
+ register(api: OpenClawPluginApi) {
48
+ setPopoNativeRuntime(api.runtime);
49
+ api.registerChannel({ plugin: popoNativePlugin });
50
+ },
51
+ };
52
+
53
+ export default plugin;
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@shenhh/popo-native",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "OpenClaw POPO Native API channel plugin",
6
+ "license": "MIT",
7
+ "files": [
8
+ "index.ts",
9
+ "src",
10
+ "openclaw.plugin.json"
11
+ ],
12
+ "author": {
13
+ "name": "Hengheng Shen",
14
+ "email": "1048157315@qq.com"
15
+ },
16
+ "publishConfig": {
17
+ "cache": "~/.npm",
18
+ "access": "public"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/m1heng/clawdbot-popo-native.git"
23
+ },
24
+ "keywords": [
25
+ "openclaw",
26
+ "popo",
27
+ "netease",
28
+ "chatbot",
29
+ "ai",
30
+ "claude",
31
+ "native-api"
32
+ ],
33
+ "openclaw": {
34
+ "extensions": [
35
+ "./index.ts"
36
+ ],
37
+ "channel": {
38
+ "id": "popo-native",
39
+ "label": "POPO Native",
40
+ "selectionLabel": "POPO Native (网易)",
41
+ "docsPath": "/channels/popo-native",
42
+ "docsLabel": "popo-native",
43
+ "blurb": "POPO enterprise messaging via Native API.",
44
+ "aliases": [],
45
+ "order": 81
46
+ },
47
+ "install": {
48
+ "npmSpec": "@shenhh/clawdbot-popo-native",
49
+ "localPath": ".",
50
+ "defaultChoice": "npm"
51
+ }
52
+ },
53
+ "dependencies": {
54
+ "zod": "^4.3.6"
55
+ },
56
+ "devDependencies": {
57
+ "@types/node": "^25.0.10",
58
+ "openclaw": "2026.1.29",
59
+ "tsx": "^4.21.0",
60
+ "typescript": "^5.7.0"
61
+ },
62
+ "peerDependencies": {
63
+ "openclaw": ">=2026.1.29"
64
+ }
65
+ }
@@ -0,0 +1,52 @@
1
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
3
+ import type { PopoNativeConfig, ResolvedPopoNativeAccount } from "./types.js";
4
+
5
+ export function resolvePopoNativeCredentials(cfg?: PopoNativeConfig): {
6
+ appId: string;
7
+ appSecret: string;
8
+ token?: string;
9
+ aesKey?: string;
10
+ server: string;
11
+ } | null {
12
+ const appId = cfg?.appId?.trim();
13
+ const appSecret = cfg?.appSecret?.trim();
14
+ if (!appId || !appSecret) return null;
15
+ return {
16
+ appId,
17
+ appSecret,
18
+ token: cfg?.token?.trim() || undefined,
19
+ aesKey: cfg?.aesKey?.trim() || undefined,
20
+ server: cfg?.server ?? "https://open.popo.netease.com",
21
+ };
22
+ }
23
+
24
+ export function resolvePopoNativeAccount(params: {
25
+ cfg: ClawdbotConfig;
26
+ accountId?: string | null;
27
+ }): ResolvedPopoNativeAccount {
28
+ const popoCfg = params.cfg.channels?.["popo-native"] as PopoNativeConfig | undefined;
29
+ const enabled = popoCfg?.enabled !== false;
30
+ const creds = resolvePopoNativeCredentials(popoCfg);
31
+
32
+ return {
33
+ accountId: params.accountId?.trim() || DEFAULT_ACCOUNT_ID,
34
+ enabled,
35
+ configured: Boolean(creds),
36
+ appId: creds?.appId,
37
+ };
38
+ }
39
+
40
+ export function listPopoNativeAccountIds(_cfg: ClawdbotConfig): string[] {
41
+ return [DEFAULT_ACCOUNT_ID];
42
+ }
43
+
44
+ export function resolveDefaultPopoNativeAccountId(_cfg: ClawdbotConfig): string {
45
+ return DEFAULT_ACCOUNT_ID;
46
+ }
47
+
48
+ export function listEnabledPopoNativeAccounts(cfg: ClawdbotConfig): ResolvedPopoNativeAccount[] {
49
+ return listPopoNativeAccountIds(cfg)
50
+ .map((accountId) => resolvePopoNativeAccount({ cfg, accountId }))
51
+ .filter((account) => account.enabled && account.configured);
52
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,86 @@
1
+ import type { PopoNativeConfig, PopoNativeToken } from "./types.js";
2
+ import { resolvePopoNativeCredentials } from "./accounts.js";
3
+
4
+ // Token cache
5
+ let cachedToken: PopoNativeToken | null = null;
6
+ let cachedAppId: string | null = null;
7
+
8
+ /**
9
+ * Get a valid access token, fetching a new one if necessary.
10
+ * Native API has 24h expiry and no refresh token.
11
+ */
12
+ export async function getAccessToken(cfg: PopoNativeConfig): Promise<string> {
13
+ const creds = resolvePopoNativeCredentials(cfg);
14
+ if (!creds) {
15
+ throw new Error("POPO Native credentials not configured (appId, appSecret required)");
16
+ }
17
+
18
+ const now = Date.now();
19
+
20
+ // Check if we have a valid cached token (with 1 minute buffer)
21
+ if (
22
+ cachedToken &&
23
+ cachedAppId === creds.appId &&
24
+ cachedToken.accessExpiredAt > now + 60000
25
+ ) {
26
+ return cachedToken.openAccessToken;
27
+ }
28
+
29
+ // Get a new token
30
+ cachedToken = await fetchNewToken(cfg);
31
+ cachedAppId = creds.appId;
32
+ return cachedToken.openAccessToken;
33
+ }
34
+
35
+ /**
36
+ * Fetch a new token using appId and appSecret.
37
+ * Native API: POST /open-apis/token
38
+ * Response: { openAccessToken, accessExpiredAt }
39
+ */
40
+ async function fetchNewToken(cfg: PopoNativeConfig): Promise<PopoNativeToken> {
41
+ const creds = resolvePopoNativeCredentials(cfg);
42
+ if (!creds) {
43
+ throw new Error("POPO Native credentials not configured");
44
+ }
45
+
46
+ const response = await fetch(`${creds.server}/open-apis/token`, {
47
+ method: "POST",
48
+ headers: {
49
+ "Content-Type": "application/json",
50
+ },
51
+ body: JSON.stringify({
52
+ appId: creds.appId,
53
+ appSecret: creds.appSecret,
54
+ }),
55
+ });
56
+
57
+ if (!response.ok) {
58
+ throw new Error(`POPO Native token request failed: ${response.status} ${response.statusText}`);
59
+ }
60
+
61
+ const data = (await response.json()) as {
62
+ errcode?: number;
63
+ errmsg?: string;
64
+ data?: {
65
+ openAccessToken: string;
66
+ accessExpiredAt: number;
67
+ };
68
+ };
69
+
70
+ if (data.errcode !== 0 || !data.data) {
71
+ throw new Error(`POPO Native token request failed: ${data.errmsg || "unknown error"}`);
72
+ }
73
+
74
+ return {
75
+ openAccessToken: data.data.openAccessToken,
76
+ accessExpiredAt: data.data.accessExpiredAt,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Clear the token cache.
82
+ */
83
+ export function clearTokenCache() {
84
+ cachedToken = null;
85
+ cachedAppId = null;
86
+ }
package/src/bot.ts ADDED
@@ -0,0 +1,363 @@
1
+ import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
2
+ import {
3
+ buildPendingHistoryContextFromMap,
4
+ recordPendingHistoryEntryIfEnabled,
5
+ clearHistoryEntriesIfEnabled,
6
+ DEFAULT_GROUP_HISTORY_LIMIT,
7
+ type HistoryEntry,
8
+ } from "openclaw/plugin-sdk";
9
+ import type { PopoNativeConfig, PopoNativeMediaInfo } from "./types.js";
10
+ import { getPopoNativeRuntime } from "./runtime.js";
11
+ import {
12
+ resolvePopoNativeGroupConfig,
13
+ resolvePopoNativeReplyPolicy,
14
+ resolvePopoNativeAllowlistMatch,
15
+ isPopoNativeGroupAllowed,
16
+ } from "./policy.js";
17
+ import { createPopoNativeReplyDispatcher } from "./reply-dispatcher.js";
18
+ import { downloadFilePopoNative } from "./media.js";
19
+ import type { PopoNativeMsgSendEvent } from "./types.js";
20
+
21
+ export type PopoNativeMessageEvent = PopoNativeMsgSendEvent;
22
+
23
+ function parseMessageContent(notify: string, msgType?: number): string {
24
+ // msgType: 1=text, 142=video, 171=file, 161=merge, 211=quote
25
+ if (msgType === 1 || !msgType) {
26
+ return notify;
27
+ }
28
+ return notify;
29
+ }
30
+
31
+ /**
32
+ * Infer placeholder text based on message type.
33
+ */
34
+ function inferPlaceholder(msgType?: number): string {
35
+ switch (msgType) {
36
+ case 2: // image
37
+ return "<media:image>";
38
+ case 171: // file
39
+ return "<media:document>";
40
+ case 142: // video
41
+ return "<media:video>";
42
+ case 3: // audio
43
+ return "<media:audio>";
44
+ default:
45
+ return "";
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Resolve media from a POPO message, downloading and saving to disk.
51
+ */
52
+ async function resolvePopoNativeMediaList(params: {
53
+ cfg: ClawdbotConfig;
54
+ event: PopoNativeMessageEvent;
55
+ maxBytes: number;
56
+ log?: (msg: string) => void;
57
+ }): Promise<PopoNativeMediaInfo[]> {
58
+ const { cfg, event, maxBytes, log } = params;
59
+ const { msgType, fileInfo, videoInfo } = event.eventData;
60
+
61
+ // Only process media message types
62
+ // 2=image, 142=video, 171=file
63
+ const fileId = fileInfo?.fileId || videoInfo?.videoId;
64
+ if (!fileId || (msgType !== 2 && msgType !== 142 && msgType !== 171)) {
65
+ return [];
66
+ }
67
+
68
+ const out: PopoNativeMediaInfo[] = [];
69
+ const core = getPopoNativeRuntime();
70
+
71
+ try {
72
+ const result = await downloadFilePopoNative({
73
+ cfg,
74
+ fileId,
75
+ });
76
+
77
+ let contentType = result.contentType;
78
+ if (!contentType) {
79
+ contentType = await core.media.detectMime({ buffer: result.buffer });
80
+ }
81
+
82
+ const saved = await core.channel.media.saveMediaBuffer(
83
+ result.buffer,
84
+ contentType,
85
+ "inbound",
86
+ maxBytes
87
+ );
88
+
89
+ out.push({
90
+ path: saved.path,
91
+ contentType: saved.contentType,
92
+ placeholder: inferPlaceholder(msgType),
93
+ });
94
+
95
+ log?.(`popo-native: downloaded media (type=${msgType}), saved to ${saved.path}`);
96
+ } catch (err) {
97
+ log?.(`popo-native: failed to download media: ${String(err)}`);
98
+ }
99
+
100
+ return out;
101
+ }
102
+
103
+ /**
104
+ * Build media payload for inbound context.
105
+ */
106
+ function buildPopoNativeMediaPayload(
107
+ mediaList: PopoNativeMediaInfo[]
108
+ ): {
109
+ MediaPath?: string;
110
+ MediaType?: string;
111
+ MediaUrl?: string;
112
+ MediaPaths?: string[];
113
+ MediaUrls?: string[];
114
+ MediaTypes?: string[];
115
+ } {
116
+ const first = mediaList[0];
117
+ const mediaPaths = mediaList.map((media) => media.path);
118
+ const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[];
119
+ return {
120
+ MediaPath: first?.path,
121
+ MediaType: first?.contentType,
122
+ MediaUrl: first?.path,
123
+ MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
124
+ MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
125
+ MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
126
+ };
127
+ }
128
+
129
+ export function parsePopoNativeMessageEvent(event: PopoNativeMessageEvent): {
130
+ sessionId: string;
131
+ messageId: string;
132
+ senderId: string;
133
+ senderEmail: string;
134
+ senderName?: string;
135
+ chatType: "p2p" | "group";
136
+ content: string;
137
+ contentType: string;
138
+ fileId?: string;
139
+ } {
140
+ const { eventData } = event;
141
+ const isGroup = eventData.sessionType === 3;
142
+ const content = parseMessageContent(eventData.notify, eventData.msgType);
143
+
144
+ return {
145
+ sessionId: eventData.sessionId,
146
+ messageId: eventData.uuid,
147
+ senderId: eventData.from,
148
+ senderEmail: eventData.from,
149
+ chatType: isGroup ? "group" : "p2p",
150
+ content,
151
+ contentType: String(eventData.msgType ?? "1"),
152
+ fileId: eventData.fileInfo?.fileId || eventData.videoInfo?.videoId,
153
+ };
154
+ }
155
+
156
+ export async function handlePopoNativeMessage(params: {
157
+ cfg: ClawdbotConfig;
158
+ event: PopoNativeMessageEvent;
159
+ runtime?: RuntimeEnv;
160
+ chatHistories?: Map<string, HistoryEntry[]>;
161
+ }): Promise<void> {
162
+ const { cfg, event, runtime, chatHistories } = params;
163
+ const popoCfg = cfg.channels?.["popo-native"] as PopoNativeConfig | undefined;
164
+ const log = runtime?.log ?? console.log;
165
+ const error = runtime?.error ?? console.error;
166
+
167
+ const ctx = parsePopoNativeMessageEvent(event);
168
+ const isGroup = ctx.chatType === "group";
169
+
170
+ log(`popo-native: received message from ${ctx.senderEmail} in ${ctx.sessionId} (${ctx.chatType})`);
171
+
172
+ const historyLimit = Math.max(
173
+ 0,
174
+ popoCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT
175
+ );
176
+
177
+ if (isGroup) {
178
+ const groupPolicy = popoCfg?.groupPolicy ?? "open";
179
+ const groupAllowFrom = popoCfg?.groupAllowFrom ?? [];
180
+ const groupConfig = resolvePopoNativeGroupConfig({ cfg: popoCfg, groupId: ctx.sessionId });
181
+
182
+ // Check if this GROUP is allowed
183
+ const groupAllowed = isPopoNativeGroupAllowed({
184
+ groupPolicy,
185
+ allowFrom: groupAllowFrom,
186
+ senderId: ctx.sessionId,
187
+ senderName: undefined,
188
+ });
189
+
190
+ if (!groupAllowed) {
191
+ log(`popo-native: group ${ctx.sessionId} not in allowlist`);
192
+ return;
193
+ }
194
+
195
+ // Additional sender-level allowlist check if group has specific allowFrom config
196
+ const senderAllowFrom = groupConfig?.allowFrom ?? [];
197
+ if (senderAllowFrom.length > 0) {
198
+ const senderAllowed = isPopoNativeGroupAllowed({
199
+ groupPolicy: "allowlist",
200
+ allowFrom: senderAllowFrom,
201
+ senderId: ctx.senderEmail,
202
+ senderName: ctx.senderName,
203
+ });
204
+ if (!senderAllowed) {
205
+ log(`popo-native: sender ${ctx.senderEmail} not in group ${ctx.sessionId} sender allowlist`);
206
+ return;
207
+ }
208
+ }
209
+
210
+ // Check @ mention requirement
211
+ const requireMention = groupConfig?.requireMention ?? popoCfg?.requireMention ?? true;
212
+ if (requireMention && event.eventData.atType !== 1 && event.eventData.atType !== 2) {
213
+ log(`popo-native: message not @ mentioning bot, skipping`);
214
+ return;
215
+ }
216
+ } else {
217
+ const dmPolicy = popoCfg?.dmPolicy ?? "pairing";
218
+ const allowFrom = popoCfg?.allowFrom ?? [];
219
+
220
+ if (dmPolicy === "allowlist") {
221
+ const match = resolvePopoNativeAllowlistMatch({
222
+ allowFrom,
223
+ senderId: ctx.senderEmail,
224
+ });
225
+ if (!match.allowed) {
226
+ log(`popo-native: sender ${ctx.senderEmail} not in DM allowlist`);
227
+ return;
228
+ }
229
+ }
230
+ }
231
+
232
+ try {
233
+ const core = getPopoNativeRuntime();
234
+
235
+ const popoFrom = `popo-native:${ctx.senderEmail}`;
236
+ const popoTo = isGroup ? `group:${ctx.sessionId}` : `user:${ctx.senderEmail}`;
237
+
238
+ const route = core.channel.routing.resolveAgentRoute({
239
+ cfg,
240
+ channel: "popo-native",
241
+ peer: {
242
+ kind: isGroup ? "group" : "dm",
243
+ id: ctx.sessionId,
244
+ },
245
+ });
246
+
247
+ const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
248
+ const inboundLabel = isGroup
249
+ ? `POPO Native message in group ${ctx.sessionId}`
250
+ : `POPO Native DM from ${ctx.senderEmail}`;
251
+
252
+ core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
253
+ sessionKey: route.sessionKey,
254
+ contextKey: `popo-native:message:${ctx.sessionId}:${ctx.messageId}`,
255
+ });
256
+
257
+ // Resolve media from message
258
+ const mediaMaxBytes = (popoCfg?.mediaMaxMb ?? 20) * 1024 * 1024;
259
+ const mediaList = await resolvePopoNativeMediaList({
260
+ cfg,
261
+ event,
262
+ maxBytes: mediaMaxBytes,
263
+ log,
264
+ });
265
+ const mediaPayload = buildPopoNativeMediaPayload(mediaList);
266
+
267
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
268
+
269
+ // Build message body
270
+ let messageBody = ctx.content;
271
+ const speaker = ctx.senderName ?? ctx.senderEmail;
272
+ messageBody = `${speaker}: ${messageBody}`;
273
+
274
+ // Apply global system prompt if configured
275
+ const systemPrompt = popoCfg?.systemPrompt?.trim();
276
+ if (systemPrompt) {
277
+ messageBody = `${systemPrompt}\n\n---\n\n${messageBody}`;
278
+ }
279
+
280
+ const envelopeFrom = isGroup ? `${ctx.sessionId}:${ctx.senderEmail}` : ctx.senderEmail;
281
+
282
+ const body = core.channel.reply.formatAgentEnvelope({
283
+ channel: "POPO Native",
284
+ from: envelopeFrom,
285
+ timestamp: new Date(),
286
+ envelope: envelopeOptions,
287
+ body: messageBody,
288
+ });
289
+
290
+ let combinedBody = body;
291
+ const historyKey = isGroup ? ctx.sessionId : undefined;
292
+
293
+ if (isGroup && historyKey && chatHistories) {
294
+ combinedBody = buildPendingHistoryContextFromMap({
295
+ historyMap: chatHistories,
296
+ historyKey,
297
+ limit: historyLimit,
298
+ currentMessage: combinedBody,
299
+ formatEntry: (entry) =>
300
+ core.channel.reply.formatAgentEnvelope({
301
+ channel: "POPO Native",
302
+ from: `${ctx.sessionId}:${entry.sender}`,
303
+ timestamp: entry.timestamp,
304
+ body: entry.body,
305
+ envelope: envelopeOptions,
306
+ }),
307
+ });
308
+ }
309
+
310
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
311
+ Body: combinedBody,
312
+ RawBody: ctx.content,
313
+ CommandBody: ctx.content,
314
+ From: popoFrom,
315
+ To: popoTo,
316
+ SessionKey: route.sessionKey,
317
+ AccountId: route.accountId,
318
+ ChatType: isGroup ? "group" : "direct",
319
+ GroupSubject: isGroup ? ctx.sessionId : undefined,
320
+ SenderName: ctx.senderName ?? ctx.senderEmail,
321
+ SenderId: ctx.senderEmail,
322
+ Provider: "popo-native" as const,
323
+ Surface: "popo-native" as const,
324
+ MessageSid: ctx.messageId,
325
+ Timestamp: Date.now(),
326
+ WasMentioned: isGroup && (event.eventData.atType === 1 || event.eventData.atType === 2),
327
+ CommandAuthorized: true,
328
+ OriginatingChannel: "popo-native" as const,
329
+ OriginatingTo: popoTo,
330
+ ...mediaPayload,
331
+ });
332
+
333
+ const { dispatcher, replyOptions, markDispatchIdle } = createPopoNativeReplyDispatcher({
334
+ cfg,
335
+ agentId: route.agentId,
336
+ runtime: runtime as RuntimeEnv,
337
+ sessionId: ctx.sessionId,
338
+ });
339
+
340
+ log(`popo-native: dispatching to agent (session=${route.sessionKey})`);
341
+
342
+ const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
343
+ ctx: ctxPayload,
344
+ cfg,
345
+ dispatcher,
346
+ replyOptions,
347
+ });
348
+
349
+ markDispatchIdle();
350
+
351
+ if (isGroup && historyKey && chatHistories) {
352
+ clearHistoryEntriesIfEnabled({
353
+ historyMap: chatHistories,
354
+ historyKey,
355
+ limit: historyLimit,
356
+ });
357
+ }
358
+
359
+ log(`popo-native: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`);
360
+ } catch (err) {
361
+ error(`popo-native: failed to dispatch message: ${String(err)}`);
362
+ }
363
+ }