shennian 0.2.73 → 0.2.75

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.
@@ -0,0 +1,77 @@
1
+ export type MacWeChatRpaFlowOptions = {
2
+ groupName: string;
3
+ replyText?: string;
4
+ attachmentPath?: string;
5
+ scriptPath?: string;
6
+ workDir?: string;
7
+ forceForeground?: boolean;
8
+ noRestore?: boolean;
9
+ idleSeconds?: number;
10
+ recentLimit?: number;
11
+ downloadAttachmentsDir?: string;
12
+ timeoutMs?: number;
13
+ cloudOcrUrl?: string;
14
+ cloudOcrToken?: string;
15
+ cloudOcrMode?: 'off' | 'fallback' | 'always';
16
+ cloudOcrChannelId?: string;
17
+ };
18
+ export type MacWeChatRpaFlowResult = {
19
+ ok: boolean;
20
+ groupName: string;
21
+ interrupted?: boolean;
22
+ reason?: string;
23
+ screenshotPath?: string;
24
+ recentMessages?: MacWeChatRpaFlowMessage[];
25
+ newMessages?: MacWeChatRpaFlowMessage[];
26
+ sentReply?: boolean;
27
+ sentReplyObserved?: boolean;
28
+ sentAttachment?: boolean;
29
+ sentAttachmentObserved?: boolean;
30
+ postSendScreenshotPath?: string;
31
+ cloudOcrPurpose?: MacWeChatRpaCloudOcrPurpose;
32
+ cloudOcrObservations?: MacWeChatRpaCloudOcrObservation[];
33
+ cloudOcrRequestId?: string;
34
+ cloudOcrImageHash?: string;
35
+ cloudOcrUsage?: MacWeChatRpaCloudOcrUsage;
36
+ error?: string;
37
+ };
38
+ export type MacWeChatRpaCloudOcrPurpose = 'message-read' | 'attachment-localization' | 'send-confirmation';
39
+ export type MacWeChatRpaFlowMessage = {
40
+ id?: string;
41
+ text?: string;
42
+ confidence?: number;
43
+ attachments?: MacWeChatRpaAttachment[];
44
+ };
45
+ export type MacWeChatRpaAttachment = {
46
+ type: string;
47
+ name?: string;
48
+ mimeType?: string;
49
+ size?: number;
50
+ url?: string;
51
+ localPath?: string;
52
+ thumbnailPath?: string;
53
+ hash?: string;
54
+ availability?: 'edge-local' | 'server-url' | 'pending-download' | 'metadata-only' | 'unavailable-large';
55
+ machineId?: string;
56
+ expiresAt?: string;
57
+ providerError?: string;
58
+ };
59
+ export type MacWeChatRpaCloudOcrObservation = {
60
+ text: string;
61
+ confidence?: number;
62
+ role?: string;
63
+ attachment?: MacWeChatRpaAttachment;
64
+ };
65
+ export type MacWeChatRpaCloudOcrUsage = {
66
+ inputTokens?: number;
67
+ outputTokens?: number;
68
+ totalTokens?: number;
69
+ };
70
+ export declare function runMacWeChatRpaFlow(options: MacWeChatRpaFlowOptions): Promise<MacWeChatRpaFlowResult>;
71
+ export declare function selectCloudOcrRequest(result: Pick<MacWeChatRpaFlowResult, 'newMessages' | 'recentMessages' | 'screenshotPath' | 'postSendScreenshotPath' | 'sentReply' | 'sentAttachment'>, mode: 'off' | 'fallback' | 'always'): {
72
+ screenshotPath: string;
73
+ purpose: MacWeChatRpaCloudOcrPurpose;
74
+ } | null;
75
+ export declare function shouldUseCloudOcr(result: Pick<MacWeChatRpaFlowResult, 'newMessages' | 'recentMessages'>, mode: 'off' | 'fallback' | 'always'): boolean;
76
+ export declare function mergeCloudMessages(localMessages: MacWeChatRpaFlowMessage[], cloudMessages: MacWeChatRpaFlowMessage[]): MacWeChatRpaFlowMessage[];
77
+ export declare function messagesFromCloudOcrObservations(groupName: string, observations: MacWeChatRpaCloudOcrObservation[]): MacWeChatRpaFlowMessage[];
@@ -0,0 +1,254 @@
1
+ // @arch docs/features/wechat-rpa-channel.md
2
+ // @test src/__tests__/wechat-rpa-normalizer.test.ts
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import crypto from 'node:crypto';
6
+ import { execFile } from 'node:child_process';
7
+ import { promisify } from 'node:util';
8
+ const execFileAsync = promisify(execFile);
9
+ export async function runMacWeChatRpaFlow(options) {
10
+ if (process.platform !== 'darwin') {
11
+ throw new Error('WeChat RPA macOS flow can only run on macOS');
12
+ }
13
+ const scriptPath = resolveFlowScriptPath(options.scriptPath, options.workDir);
14
+ const args = [
15
+ scriptPath,
16
+ '--group',
17
+ options.groupName,
18
+ '--idle-seconds',
19
+ String(Number.isFinite(options.idleSeconds) ? options.idleSeconds : 15),
20
+ ];
21
+ if (options.forceForeground)
22
+ args.push('--force');
23
+ if (options.noRestore)
24
+ args.push('--no-restore');
25
+ if (options.replyText)
26
+ args.push('--reply-text', options.replyText);
27
+ if (options.attachmentPath)
28
+ args.push('--attachment-path', options.attachmentPath);
29
+ if (options.downloadAttachmentsDir)
30
+ args.push('--download-attachments-dir', options.downloadAttachmentsDir);
31
+ if (Number.isFinite(options.recentLimit) && Number(options.recentLimit) > 0) {
32
+ args.push('--recent-limit', String(options.recentLimit));
33
+ }
34
+ let stdout = '';
35
+ let stderr = '';
36
+ try {
37
+ const result = await execFileAsync(process.execPath, args, {
38
+ cwd: options.workDir || process.cwd(),
39
+ timeout: options.timeoutMs ?? 120_000,
40
+ maxBuffer: 20 * 1024 * 1024,
41
+ });
42
+ stdout = result.stdout;
43
+ stderr = result.stderr;
44
+ }
45
+ catch (error) {
46
+ const execError = error;
47
+ stdout = execError.stdout || '';
48
+ stderr = execError.stderr || '';
49
+ const parsed = parseFlowStdout(stdout);
50
+ if (parsed?.interrupted)
51
+ return parsed;
52
+ throw new Error(stderr.trim() || stdout.slice(0, 1_000) || execError.message || 'WeChat RPA flow failed');
53
+ }
54
+ try {
55
+ const parsed = JSON.parse(stdout);
56
+ if (parsed.interrupted)
57
+ return parsed;
58
+ if (!parsed.ok)
59
+ throw new Error(parsed.error || 'WeChat RPA flow failed');
60
+ return await maybeEnrichWithCloudOcr(parsed, options);
61
+ }
62
+ catch (error) {
63
+ const detail = stderr.trim() || stdout.slice(0, 1_000);
64
+ throw new Error(detail || (error instanceof Error ? error.message : String(error)));
65
+ }
66
+ }
67
+ function parseFlowStdout(stdout) {
68
+ try {
69
+ return JSON.parse(stdout);
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
75
+ async function maybeEnrichWithCloudOcr(result, options) {
76
+ const mode = options.cloudOcrMode ?? 'off';
77
+ const request = selectCloudOcrRequest(result, mode);
78
+ if (!request || !options.cloudOcrUrl || !options.cloudOcrToken)
79
+ return result;
80
+ const imageBase64 = fs.readFileSync(request.screenshotPath).toString('base64');
81
+ const response = await fetch(options.cloudOcrUrl, {
82
+ method: 'POST',
83
+ headers: {
84
+ authorization: `Bearer ${options.cloudOcrToken}`,
85
+ 'content-type': 'application/json',
86
+ },
87
+ body: JSON.stringify({
88
+ imageBase64,
89
+ mimeType: 'image/png',
90
+ conversationName: options.groupName,
91
+ purpose: request.purpose,
92
+ channelId: options.cloudOcrChannelId,
93
+ }),
94
+ });
95
+ if (!response.ok)
96
+ return result;
97
+ const payload = await response.json().catch(() => null);
98
+ const observations = Array.isArray(payload?.observations) ? payload.observations : [];
99
+ if (!observations.length)
100
+ return result;
101
+ const cloudMessages = messagesFromCloudOcrObservations(options.groupName, observations);
102
+ const recentLimit = Math.max(1, options.recentLimit || 5);
103
+ return {
104
+ ...result,
105
+ cloudOcrPurpose: request.purpose,
106
+ cloudOcrObservations: observations,
107
+ ...(payload?.requestId ? { cloudOcrRequestId: payload.requestId } : {}),
108
+ ...(payload?.imageHash ? { cloudOcrImageHash: payload.imageHash } : {}),
109
+ ...(payload?.usage ? { cloudOcrUsage: payload.usage } : {}),
110
+ newMessages: mergeCloudMessages(result.newMessages ?? [], cloudMessages),
111
+ recentMessages: mergeCloudMessages(result.recentMessages ?? [], cloudMessages).slice(-recentLimit),
112
+ };
113
+ }
114
+ export function selectCloudOcrRequest(result, mode) {
115
+ if (!shouldUseCloudOcr(result, mode))
116
+ return null;
117
+ if ((result.sentReply || result.sentAttachment) && result.postSendScreenshotPath) {
118
+ return { screenshotPath: result.postSendScreenshotPath, purpose: 'send-confirmation' };
119
+ }
120
+ if (!result.screenshotPath)
121
+ return null;
122
+ const messages = [...(result.newMessages ?? []), ...(result.recentMessages ?? [])];
123
+ const hasUnresolvedAttachment = messages.some((message) => (message.attachments ?? []).some((attachment) => attachment.availability === 'metadata-only'
124
+ || attachment.availability === 'pending-download'
125
+ || Boolean(attachment.providerError)));
126
+ return {
127
+ screenshotPath: result.screenshotPath,
128
+ purpose: hasUnresolvedAttachment ? 'attachment-localization' : 'message-read',
129
+ };
130
+ }
131
+ export function shouldUseCloudOcr(result, mode) {
132
+ if (mode === 'off')
133
+ return false;
134
+ if (mode === 'always')
135
+ return true;
136
+ const messages = [...(result.newMessages ?? []), ...(result.recentMessages ?? [])];
137
+ if (!(result.newMessages?.length))
138
+ return true;
139
+ if (messages.some((message) => Number.isFinite(message.confidence) && Number(message.confidence) < 0.65))
140
+ return true;
141
+ return messages.some((message) => (message.attachments ?? []).some((attachment) => attachment.availability === 'metadata-only'
142
+ || attachment.availability === 'pending-download'
143
+ || Boolean(attachment.providerError)));
144
+ }
145
+ export function mergeCloudMessages(localMessages, cloudMessages) {
146
+ const merged = [...localMessages];
147
+ for (const cloud of cloudMessages) {
148
+ const signature = messageSignature(cloud);
149
+ const index = merged.findIndex((message) => messageSignature(message) === signature);
150
+ if (index < 0) {
151
+ merged.push(cloud);
152
+ continue;
153
+ }
154
+ const existingAttachments = merged[index]?.attachments ?? [];
155
+ const cloudAttachments = cloud.attachments ?? [];
156
+ if (!existingAttachments.length && cloudAttachments.length) {
157
+ const existing = merged[index] ?? {};
158
+ merged[index] = { ...existing, attachments: cloudAttachments };
159
+ }
160
+ }
161
+ return merged;
162
+ }
163
+ export function messagesFromCloudOcrObservations(groupName, observations) {
164
+ const messages = [];
165
+ for (const item of observations) {
166
+ const text = String(item.text || '').trim();
167
+ if (!text)
168
+ continue;
169
+ const role = String(item.role || 'unknown');
170
+ if (!['message', 'attachment', 'unknown'].includes(role))
171
+ continue;
172
+ const attachment = normalizeCloudAttachment(item.attachment) ?? (role === 'attachment' ? attachmentFromText(text) : null);
173
+ messages.push({
174
+ id: `cloud:${stableId(`${groupName}\n${role}\n${text}`)}`,
175
+ text,
176
+ confidence: item.confidence,
177
+ attachments: attachment ? [attachment] : [],
178
+ });
179
+ }
180
+ return messages;
181
+ }
182
+ function messageSignature(message) {
183
+ const text = String(message.text || '').replace(/\s+/g, ' ').trim().toLowerCase();
184
+ const attachments = (message.attachments ?? [])
185
+ .map((attachment) => `${attachment.type}:${attachment.name || ''}:${attachment.mimeType || ''}`)
186
+ .sort()
187
+ .join('|');
188
+ return text || attachments || String(message.id || '');
189
+ }
190
+ function normalizeCloudAttachment(value) {
191
+ if (!value || typeof value !== 'object')
192
+ return null;
193
+ const record = value;
194
+ const type = String(record.type || 'file').trim() || 'file';
195
+ const name = String(record.name || '').replace(/\s+/g, ' ').trim();
196
+ const mimeType = String(record.mimeType || '').replace(/\s+/g, ' ').trim();
197
+ const size = Number(record.size);
198
+ return {
199
+ type: ['image', 'video', 'audio', 'file'].includes(type) ? type : 'file',
200
+ ...(name ? { name } : {}),
201
+ ...(mimeType ? { mimeType } : {}),
202
+ ...(Number.isFinite(size) && size > 0 ? { size } : {}),
203
+ availability: 'metadata-only',
204
+ };
205
+ }
206
+ function attachmentFromText(text) {
207
+ const matches = Array.from(text.matchAll(/[\p{L}\p{N}_().[\]-]+\.(png|jpe?g|gif|webp|heic|mp4|mov|avi|mkv|pdf|docx?|xlsx?|pptx?|txt|md|csv|zip|rar)/giu));
208
+ const filename = matches.at(-1)?.[0]?.trim();
209
+ if (!filename)
210
+ return null;
211
+ const ext = path.extname(filename).toLowerCase();
212
+ return { type: attachmentTypeFromExt(ext), name: filename, mimeType: mimeTypeFromExt(ext), availability: 'metadata-only' };
213
+ }
214
+ function attachmentTypeFromExt(ext) {
215
+ if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.heic'].includes(ext))
216
+ return 'image';
217
+ if (['.mp4', '.mov', '.avi', '.mkv'].includes(ext))
218
+ return 'video';
219
+ return 'file';
220
+ }
221
+ function mimeTypeFromExt(ext) {
222
+ const map = {
223
+ '.png': 'image/png',
224
+ '.jpg': 'image/jpeg',
225
+ '.jpeg': 'image/jpeg',
226
+ '.gif': 'image/gif',
227
+ '.webp': 'image/webp',
228
+ '.heic': 'image/heic',
229
+ '.mp4': 'video/mp4',
230
+ '.mov': 'video/quicktime',
231
+ '.pdf': 'application/pdf',
232
+ '.txt': 'text/plain',
233
+ '.md': 'text/markdown',
234
+ '.csv': 'text/csv',
235
+ '.zip': 'application/zip',
236
+ };
237
+ return map[ext] || 'application/octet-stream';
238
+ }
239
+ function stableId(value) {
240
+ return crypto.createHash('sha256').update(value).digest('hex').slice(0, 24);
241
+ }
242
+ function resolveFlowScriptPath(scriptPath, workDir) {
243
+ const candidates = [
244
+ scriptPath,
245
+ process.env.SHENNIAN_WECHAT_RPA_FLOW_SCRIPT,
246
+ workDir ? path.join(workDir, 'scripts/wechat-rpa-flow.mjs') : '',
247
+ path.resolve(process.cwd(), 'scripts/wechat-rpa-flow.mjs'),
248
+ ].filter(Boolean);
249
+ const found = candidates.find((candidate) => fs.existsSync(candidate));
250
+ if (!found) {
251
+ throw new Error('WeChat RPA flow script is missing; set SHENNIAN_WECHAT_RPA_FLOW_SCRIPT or channel flowScriptPath');
252
+ }
253
+ return path.resolve(found);
254
+ }
@@ -2,9 +2,24 @@ export type WeChatRpaObservedMessage = {
2
2
  conversationName: string;
3
3
  senderName?: string | null;
4
4
  text: string;
5
+ attachments?: WeChatRpaObservedAttachment[];
5
6
  observedAt?: string | null;
6
7
  rawId?: string | null;
7
8
  };
9
+ export type WeChatRpaObservedAttachment = {
10
+ type: string;
11
+ name?: string;
12
+ url?: string;
13
+ mimeType?: string;
14
+ size?: number;
15
+ localPath?: string;
16
+ thumbnailPath?: string;
17
+ hash?: string;
18
+ availability?: 'edge-local' | 'server-url' | 'pending-download' | 'metadata-only' | 'unavailable-large';
19
+ machineId?: string;
20
+ expiresAt?: string;
21
+ providerError?: string;
22
+ };
8
23
  export type WeChatRpaNormalizedMessage = {
9
24
  conversationId: string;
10
25
  conversationName: string;
@@ -14,6 +29,7 @@ export type WeChatRpaNormalizedMessage = {
14
29
  name?: string | null;
15
30
  };
16
31
  text: string;
32
+ attachments: WeChatRpaObservedAttachment[];
17
33
  receivedAt: string;
18
34
  rawRef: string | null;
19
35
  };
@@ -23,3 +39,4 @@ export declare class WeChatRpaDeduper {
23
39
  accept(messageId: string): boolean;
24
40
  }
25
41
  export declare function normalizeWeChatRpaMessage(input: WeChatRpaObservedMessage): WeChatRpaNormalizedMessage | null;
42
+ export declare function weChatRpaConversationId(conversationName: string): string;
@@ -21,29 +21,73 @@ export class WeChatRpaDeduper {
21
21
  export function normalizeWeChatRpaMessage(input) {
22
22
  const conversationName = cleanText(input.conversationName);
23
23
  const text = cleanText(input.text);
24
- if (!conversationName || !text)
24
+ const attachments = normalizeAttachments(input.attachments);
25
+ if (!conversationName || (!text && attachments.length === 0))
25
26
  return null;
26
27
  const senderName = cleanText(input.senderName || '') || null;
27
28
  const receivedAt = normalizeIso(input.observedAt);
28
- const conversationId = stableId('wechat-conversation', conversationName);
29
+ const conversationId = weChatRpaConversationId(conversationName);
29
30
  const senderId = stableId('wechat-sender', senderName || 'unknown');
30
31
  const rawRef = cleanText(input.rawId || '') || null;
31
32
  const messageId = rawRef
32
33
  ? stableId('wechat-message', `${conversationName}\n${rawRef}`)
33
- : stableId('wechat-message', `${conversationName}\n${senderName || ''}\n${text}\n${receivedAt}`);
34
+ : stableId('wechat-message', `${conversationName}\n${senderName || ''}\n${text}\n${attachments.map((item) => item.name || item.url || item.type).join('\n')}\n${receivedAt}`);
34
35
  return {
35
36
  conversationId,
36
37
  conversationName,
37
38
  messageId,
38
39
  sender: { id: senderId, name: senderName },
39
40
  text,
41
+ attachments,
40
42
  receivedAt,
41
43
  rawRef,
42
44
  };
43
45
  }
46
+ export function weChatRpaConversationId(conversationName) {
47
+ return stableId('wechat-conversation', cleanText(conversationName));
48
+ }
44
49
  function cleanText(value) {
45
50
  return value.replace(/\s+/g, ' ').trim();
46
51
  }
52
+ function normalizeAttachments(value) {
53
+ if (!Array.isArray(value))
54
+ return [];
55
+ return value
56
+ .map((item) => {
57
+ const url = item?.url ? cleanText(item.url) : '';
58
+ const localPath = item?.localPath ? cleanText(item.localPath) : '';
59
+ const availability = normalizeAvailability(item?.availability, { url, localPath });
60
+ return {
61
+ type: cleanText(String(item?.type || 'file')) || 'file',
62
+ ...(item?.name ? { name: cleanText(item.name) } : {}),
63
+ ...(url ? { url } : {}),
64
+ ...(item?.mimeType ? { mimeType: cleanText(item.mimeType) } : {}),
65
+ ...(Number.isFinite(item?.size) && Number(item.size) > 0 ? { size: Number(item.size) } : {}),
66
+ ...(localPath ? { localPath } : {}),
67
+ ...(item?.thumbnailPath ? { thumbnailPath: cleanText(item.thumbnailPath) } : {}),
68
+ ...(item?.hash ? { hash: cleanText(item.hash) } : {}),
69
+ ...(availability ? { availability } : {}),
70
+ ...(item?.machineId ? { machineId: cleanText(item.machineId) } : {}),
71
+ ...(item?.expiresAt ? { expiresAt: cleanText(item.expiresAt) } : {}),
72
+ ...(item?.providerError ? { providerError: cleanText(item.providerError) } : {}),
73
+ };
74
+ })
75
+ .filter((item) => item.type);
76
+ }
77
+ function normalizeAvailability(value, sources) {
78
+ if (value === 'edge-local'
79
+ || value === 'server-url'
80
+ || value === 'pending-download'
81
+ || value === 'metadata-only'
82
+ || value === 'unavailable-large') {
83
+ return value;
84
+ }
85
+ if (sources.localPath)
86
+ return 'edge-local';
87
+ if (sources.url)
88
+ return 'server-url';
89
+ return 'metadata-only';
90
+ }
47
91
  function normalizeIso(value) {
48
92
  if (!value)
49
93
  return new Date().toISOString();
@@ -1,34 +1,51 @@
1
- import type { ExternalChannelAdapter, ExternalChannelConfig, ExternalReply } from './base.js';
1
+ import type { ExternalChannelAdapter, ExternalChannelConfig, ExternalChannelRuntimeStatus, ExternalMessageAttachment, ExternalReply } from './base.js';
2
+ import { type WeChatRpaObservedMessage } from './wechat-rpa/normalizer.js';
3
+ type WeChatRpaEvent = {
4
+ managerSessionId: string;
5
+ channelId: string;
6
+ channelType: 'wechat-rpa';
7
+ conversationId: string;
8
+ messageId: string;
9
+ sender: {
10
+ id: string;
11
+ name?: string | null;
12
+ };
13
+ text: string;
14
+ attachments: ExternalMessageAttachment[];
15
+ receivedAt: string;
16
+ replyTarget: string;
17
+ rawRef?: string | null;
18
+ };
2
19
  export declare class WeChatRpaChannelAdapter implements ExternalChannelAdapter {
3
20
  private onMessage?;
4
21
  readonly type: "wechat-rpa";
5
22
  private secrets;
6
23
  private connections;
7
- constructor(onMessage?: ((event: {
8
- managerSessionId: string;
9
- channelId: string;
10
- channelType: "wechat-rpa";
11
- conversationId: string;
12
- messageId: string;
13
- sender: {
14
- id: string;
15
- name?: string | null;
16
- };
17
- text: string;
18
- attachments: [];
19
- receivedAt: string;
20
- replyTarget: string;
21
- rawRef?: string | null;
22
- }) => void) | undefined);
24
+ constructor(onMessage?: ((event: WeChatRpaEvent) => void) | undefined);
23
25
  connect(config: ExternalChannelConfig): Promise<void>;
24
26
  disconnect(config: ExternalChannelConfig): Promise<void>;
25
- send(_config: ExternalChannelConfig, _reply: ExternalReply): Promise<void>;
27
+ send(config: ExternalChannelConfig, reply: ExternalReply): Promise<{
28
+ status: 'sent' | 'queued';
29
+ reason?: string;
30
+ }>;
26
31
  health(config: ExternalChannelConfig): Promise<{
27
32
  ok: boolean;
28
33
  message?: string;
29
34
  }>;
35
+ defaultConversation(config: ExternalChannelConfig): Promise<{
36
+ conversationId: string;
37
+ conversationName?: string;
38
+ }>;
39
+ runtimeStatus(config: ExternalChannelConfig): Partial<ExternalChannelRuntimeStatus>;
30
40
  private ensureConnection;
41
+ private enqueueOperation;
31
42
  private readSecret;
32
43
  private pollOnce;
44
+ private drainPendingReplies;
33
45
  private readObservedMessages;
46
+ private seedConfiguredConversations;
47
+ private resolveConversationName;
34
48
  }
49
+ export declare function materializeWeChatRpaOutboundAttachment(workDir: string, attachment: NonNullable<ExternalReply['attachment']>): Promise<string>;
50
+ export declare function annotateWeChatRpaInboundAttachments(attachments: WeChatRpaObservedMessage['attachments']): WeChatRpaObservedMessage['attachments'];
51
+ export {};