@xgjktech/xg_cwork_im 1.11.0 → 1.11.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/index.ts +3 -0
- package/package.json +1 -1
- package/src/channel.ts +35 -3
- package/src/recommended-system-prompt.ts +3 -0
- package/src/resource-file.ts +99 -13
- package/src/send-group-message-tool.ts +12 -14
- package/src/send-service.ts +28 -15
- package/src/types.ts +2 -0
package/index.ts
CHANGED
|
@@ -11,6 +11,8 @@ import { buildGroupHistoryTool, extractXgCworkImConfig } from "./src/group-histo
|
|
|
11
11
|
import { buildSendGroupMessageTool } from "./src/send-group-message-tool.js";
|
|
12
12
|
import type { XgCworkImPluginModule } from "./src/types.js";
|
|
13
13
|
|
|
14
|
+
export { XG_IM_RECOMMENDED_CHANNEL_SYSTEM_PROMPT } from "./src/recommended-system-prompt.js";
|
|
15
|
+
|
|
14
16
|
const plugin: XgCworkImPluginModule = {
|
|
15
17
|
id: "xg_cwork_im",
|
|
16
18
|
name: "XG CWork IM Channel",
|
|
@@ -24,6 +26,7 @@ const plugin: XgCworkImPluginModule = {
|
|
|
24
26
|
if (cworkConfig) {
|
|
25
27
|
// 工具涉及网络请求 / 副作用,按文档要求标记为 optional,由用户在 Agent 的 tools.allow 中显式启用
|
|
26
28
|
api.registerTool(buildGroupHistoryTool(cworkConfig), { optional: true });
|
|
29
|
+
// 主动推送(Cron/定时等)向 IM 发纯文本;用户经 IM 对话时不用此工具
|
|
27
30
|
api.registerTool(buildSendGroupMessageTool(cworkConfig), { optional: true });
|
|
28
31
|
} else {
|
|
29
32
|
console.warn("[xg_cwork_im] channel config not found, skipping tool registration");
|
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -428,20 +428,43 @@ async function dispatchMentionedReply(args: {
|
|
|
428
428
|
}
|
|
429
429
|
}, firstReplyTimeoutMs);
|
|
430
430
|
|
|
431
|
+
let deliverCallCount = 0;
|
|
432
|
+
let deliverSkippedCount = 0;
|
|
433
|
+
|
|
431
434
|
try {
|
|
432
435
|
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
433
436
|
ctx: inboundCtx,
|
|
434
437
|
cfg,
|
|
435
438
|
dispatcherOptions: {
|
|
436
439
|
responsePrefix: "",
|
|
440
|
+
// Logged when normalizeReplyPayload decides to skip a payload (e.g. heartbeat, silent token, empty).
|
|
441
|
+
onSkip: (payload: unknown, meta: { kind: string; reason: string }) => {
|
|
442
|
+
deliverSkippedCount++;
|
|
443
|
+
log.info(
|
|
444
|
+
`${logPrefix} [deliver] Payload skipped by normalizer kind=${meta.kind} reason=${meta.reason}`,
|
|
445
|
+
);
|
|
446
|
+
},
|
|
447
|
+
// Logged when deliver throws and the dispatcher catches it (error would otherwise be silently dropped).
|
|
448
|
+
onError: (err: unknown, meta: { kind: string }) => {
|
|
449
|
+
log.error(
|
|
450
|
+
`${logPrefix} [deliver] Dispatcher caught unhandled error kind=${meta.kind}: ${String(err)}`,
|
|
451
|
+
);
|
|
452
|
+
},
|
|
437
453
|
deliver: async (payload: ReplyDeliverPayload) => {
|
|
438
454
|
try {
|
|
439
455
|
const textPart = (payload.markdown || payload.text || "").trim();
|
|
440
456
|
const hasMedia =
|
|
441
457
|
Boolean(payload.mediaUrl?.trim()) ||
|
|
442
458
|
Boolean(payload.mediaUrls?.some((u) => typeof u === "string" && u.trim()));
|
|
443
|
-
if (!textPart && !(hasMedia && !payload.isThinking))
|
|
459
|
+
if (!textPart && !(hasMedia && !payload.isThinking)) {
|
|
460
|
+
log.info(
|
|
461
|
+
`${logPrefix} [deliver] Payload has no sendable content, skipping` +
|
|
462
|
+
` (isThinking=${payload.isThinking ?? false} hasMedia=${hasMedia})`,
|
|
463
|
+
);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
444
466
|
|
|
467
|
+
deliverCallCount++;
|
|
445
468
|
if (isFirstReply) {
|
|
446
469
|
const ttfr = Date.now() - dispatchStart;
|
|
447
470
|
log.info(
|
|
@@ -490,13 +513,22 @@ async function dispatchMentionedReply(args: {
|
|
|
490
513
|
`${logPrefix} [send] Additional reply sent via HTTP: groupId=${params.groupId} preview="${preview}"`,
|
|
491
514
|
);
|
|
492
515
|
} catch (err: unknown) {
|
|
493
|
-
log.error(`${logPrefix} Reply deliver failed: ${String(err)}`);
|
|
516
|
+
log.error(`${logPrefix} [deliver] Reply deliver failed: ${String(err)}`);
|
|
494
517
|
throw err;
|
|
495
518
|
}
|
|
496
519
|
},
|
|
497
520
|
},
|
|
498
521
|
});
|
|
499
|
-
log.info(
|
|
522
|
+
log.info(
|
|
523
|
+
`${logPrefix} [dispatch] Dispatch completed for sessionKey=${route.sessionKey}` +
|
|
524
|
+
` (delivered=${deliverCallCount} skipped=${deliverSkippedCount})`,
|
|
525
|
+
);
|
|
526
|
+
if (!hasFirstReply) {
|
|
527
|
+
log.warn(
|
|
528
|
+
`${logPrefix} [dispatch] No reply was sent to user after dispatch completed` +
|
|
529
|
+
` (delivered=${deliverCallCount} skipped=${deliverSkippedCount})`,
|
|
530
|
+
);
|
|
531
|
+
}
|
|
500
532
|
} finally {
|
|
501
533
|
clearTimeout(firstReplyTimeout);
|
|
502
534
|
try {
|
package/src/resource-file.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import axios from "axios";
|
|
8
|
+
import { File } from "node:buffer";
|
|
8
9
|
import { readFile } from "node:fs/promises";
|
|
9
10
|
import path from "node:path";
|
|
10
11
|
import { fileURLToPath } from "node:url";
|
|
@@ -44,14 +45,63 @@ export function formatFromFilename(filename: string): string {
|
|
|
44
45
|
return "bin";
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
/** 仅当含 %XX 序列时 decodeURIComponent,避免对已解码的 Unicode 再 decode 抛错 */
|
|
49
|
+
export function safeDecodeUriFilenameSegment(segment: string): string {
|
|
50
|
+
const s = segment.trim();
|
|
51
|
+
if (!s) return s;
|
|
52
|
+
if (!/%[0-9A-Fa-f]{2}/.test(s)) return s;
|
|
53
|
+
try {
|
|
54
|
+
return decodeURIComponent(s.replace(/\+/g, " "));
|
|
55
|
+
} catch {
|
|
56
|
+
return s;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 从 HTTP Content-Disposition 解析文件名(优先 RFC 5987 `filename*=` UTF-8,再普通 filename=)。
|
|
62
|
+
*/
|
|
63
|
+
export function parseFilenameFromContentDisposition(header: string | undefined): string | undefined {
|
|
64
|
+
if (header == null || typeof header !== "string") return undefined;
|
|
65
|
+
const h = header.trim();
|
|
66
|
+
if (!h) return undefined;
|
|
67
|
+
|
|
68
|
+
const star = /filename\*\s*=\s*(?:UTF-8|utf-8)''([^;\r\n]+)/i.exec(h);
|
|
69
|
+
if (star?.[1]) {
|
|
70
|
+
const v = star[1].trim();
|
|
71
|
+
const decoded = safeDecodeUriFilenameSegment(v);
|
|
72
|
+
if (decoded) return decoded;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const quoted = /filename\s*=\s*"((?:[^"\\]|\\.)*)"/i.exec(h);
|
|
76
|
+
if (quoted?.[1]) {
|
|
77
|
+
const inner = quoted[1].replace(/\\(.)/g, "$1").trim();
|
|
78
|
+
if (inner) return safeDecodeUriFilenameSegment(inner);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const unquoted = /filename\s*=\s*([^;\r\n]+)/i.exec(h);
|
|
82
|
+
if (unquoted?.[1]) {
|
|
83
|
+
let v = unquoted[1].trim().replace(/^["']|["']$/g, "");
|
|
84
|
+
if (v.toLowerCase().startsWith("utf-8''")) v = v.slice(7);
|
|
85
|
+
if (v) return safeDecodeUriFilenameSegment(v);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
47
91
|
export function displayNameFromUrl(url: string): string {
|
|
48
92
|
try {
|
|
49
93
|
if (url.startsWith("file://")) {
|
|
50
94
|
return path.basename(fileURLToPath(url)) || "file";
|
|
51
95
|
}
|
|
52
96
|
const u = new URL(url);
|
|
53
|
-
const
|
|
54
|
-
|
|
97
|
+
const qName = u.searchParams.get("filename") ?? u.searchParams.get("name");
|
|
98
|
+
if (qName?.trim()) {
|
|
99
|
+
return safeDecodeUriFilenameSegment(qName.trim()) || "attachment";
|
|
100
|
+
}
|
|
101
|
+
const pathname = u.pathname || "/";
|
|
102
|
+
const last = pathname.split("/").filter(Boolean).pop() ?? "";
|
|
103
|
+
const base = safeDecodeUriFilenameSegment(last.split("?")[0] || "");
|
|
104
|
+
return base || "attachment";
|
|
55
105
|
} catch {
|
|
56
106
|
return "attachment";
|
|
57
107
|
}
|
|
@@ -124,6 +174,24 @@ async function readLocalFsIntoBuffer(fsPath: string, maxBytes: number, log?: Log
|
|
|
124
174
|
return { buffer, filename };
|
|
125
175
|
}
|
|
126
176
|
|
|
177
|
+
/**
|
|
178
|
+
* 将 OpenClaw 约定的 `/workspace/<rel>` 路径解析为本机绝对路径(跨平台)。
|
|
179
|
+
*
|
|
180
|
+
* OpenClaw 在所有平台启动时均会 chdir 到 workspace 目录,因此
|
|
181
|
+
* `process.cwd()` 即为 workspace 根,/workspace/<rel> 等价于 cwd/<rel>:
|
|
182
|
+
* - Windows : C:\Users\..\.openclaw\workspace\<rel>
|
|
183
|
+
* - macOS : ~/.openclaw/workspace/<rel>
|
|
184
|
+
* - Linux容器: /workspace/<rel>(cwd=/workspace,path.join 结果不变)
|
|
185
|
+
*
|
|
186
|
+
* 返回 null 表示路径不符合 /workspace/ 格式。
|
|
187
|
+
*/
|
|
188
|
+
export function resolveWorkspacePrefixPath(raw: string): string | null {
|
|
189
|
+
if (!raw.startsWith("/workspace/") && raw !== "/workspace") return null;
|
|
190
|
+
const rel = raw.startsWith("/workspace/") ? raw.slice("/workspace/".length) : "";
|
|
191
|
+
const cwd = process.cwd();
|
|
192
|
+
return rel ? path.join(cwd, rel) : cwd;
|
|
193
|
+
}
|
|
194
|
+
|
|
127
195
|
export async function downloadMediaBuffer(
|
|
128
196
|
url: string,
|
|
129
197
|
maxBytes: number,
|
|
@@ -132,6 +200,18 @@ export async function downloadMediaBuffer(
|
|
|
132
200
|
const raw = url.trim();
|
|
133
201
|
log?.info(`[cwork_im:media] resolve ref=${summarizeMediaRef(raw)}`);
|
|
134
202
|
|
|
203
|
+
// Handle OpenClaw /workspace/ convention: map to actual workspace directory.
|
|
204
|
+
const workspaceResolved = resolveWorkspacePrefixPath(raw);
|
|
205
|
+
if (workspaceResolved && workspaceResolved !== raw) {
|
|
206
|
+
log?.info(`[cwork_im:media] /workspace/ -> ${summarizeMediaRef(workspaceResolved)}`);
|
|
207
|
+
try {
|
|
208
|
+
return await readLocalFsIntoBuffer(workspaceResolved, maxBytes, log);
|
|
209
|
+
} catch (err: unknown) {
|
|
210
|
+
log?.error(`[cwork_im:media] readFile failed /workspace/ ${summarizeMediaRef(workspaceResolved)}: ${String(err)}`);
|
|
211
|
+
throw err;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
135
215
|
if (raw.startsWith("file://")) {
|
|
136
216
|
let fsPath: string;
|
|
137
217
|
try {
|
|
@@ -161,16 +241,11 @@ export async function downloadMediaBuffer(
|
|
|
161
241
|
});
|
|
162
242
|
const buffer = Buffer.from(res.data as ArrayBuffer);
|
|
163
243
|
let filename = displayNameFromUrl(raw);
|
|
164
|
-
const
|
|
244
|
+
const cdRaw = res.headers["content-disposition"];
|
|
245
|
+
const cd = Array.isArray(cdRaw) ? cdRaw[0] : cdRaw;
|
|
165
246
|
if (typeof cd === "string") {
|
|
166
|
-
const
|
|
167
|
-
if (
|
|
168
|
-
try {
|
|
169
|
-
filename = decodeURIComponent(m[1].trim());
|
|
170
|
-
} catch {
|
|
171
|
-
filename = m[1].trim();
|
|
172
|
-
}
|
|
173
|
-
}
|
|
247
|
+
const fromCd = parseFilenameFromContentDisposition(cd);
|
|
248
|
+
if (fromCd) filename = fromCd;
|
|
174
249
|
}
|
|
175
250
|
log?.info(`[cwork_im:media] http ok bytes=${buffer.length} name=${filename}`);
|
|
176
251
|
return { buffer, filename };
|
|
@@ -212,9 +287,10 @@ export async function uploadWholeResourceFile(args: {
|
|
|
212
287
|
log?: Log;
|
|
213
288
|
}): Promise<{ fileId: string; size: number }> {
|
|
214
289
|
const { fileUploadUrl, token, buffer, filename, formField, log } = args;
|
|
215
|
-
const blob = new Blob([new Uint8Array(buffer)]);
|
|
216
290
|
const form = new FormData();
|
|
217
|
-
|
|
291
|
+
// 使用 File 携带 UTF-8 文件名,避免部分环境下 Blob+第三参在 multipart 里被错误编码导致服务端乱码
|
|
292
|
+
const file = new File([buffer], filename, { type: "application/octet-stream" });
|
|
293
|
+
form.append(formField, file);
|
|
218
294
|
|
|
219
295
|
log?.info(`[cwork_im:upload] POST ${fileUploadUrl} field=${formField} name=${filename} bytes=${buffer.length}`);
|
|
220
296
|
|
|
@@ -239,6 +315,16 @@ export async function uploadWholeResourceFile(args: {
|
|
|
239
315
|
throw new Error(`upload HTTP ${res.status}: ${rawText.slice(0, 500)}`);
|
|
240
316
|
}
|
|
241
317
|
|
|
318
|
+
// Unified IM API response: { data, resultCode: 1, resultMsg: "" } — resultCode=1 means success.
|
|
319
|
+
if (json && typeof json === "object") {
|
|
320
|
+
const j = json as Record<string, unknown>;
|
|
321
|
+
if (j.resultCode !== undefined && j.resultCode !== 1) {
|
|
322
|
+
const msg = `upload IM API error resultCode=${j.resultCode}${j.resultMsg ? ` resultMsg=${j.resultMsg}` : ""}`;
|
|
323
|
+
log?.error(`[cwork_im:upload] ${msg} body=${rawText.slice(0, 500)}`);
|
|
324
|
+
throw new Error(msg);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
242
328
|
const fileId = extractFileIdFromUploadResponse(json ?? rawText);
|
|
243
329
|
if (!fileId) {
|
|
244
330
|
log?.error(`[cwork_im:upload] missing fileId in body=${rawText.slice(0, 500)}`);
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* XG-IM
|
|
2
|
+
* XG-IM「主动推消息」工具(供 OpenClaw 在非对话场景下调用)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* - AI 分析后需要主动发起提醒(无需用户先 @ 机器人)
|
|
4
|
+
* 用途:定时任务、Cron、后台任务等需要 **经本 channel 主动把结果推送到 IM 群** 时,发一条纯文本。
|
|
5
|
+
* **不用于**用户正通过 IM 与助手聊天时的回合内回复——那种情况走正常助手回复流,用不上本工具。
|
|
7
6
|
*
|
|
8
|
-
* 通过 api.registerTool() 注入,工具内部自动获取机器人 token
|
|
7
|
+
* 通过 api.registerTool() 注入,工具内部自动获取机器人 token。
|
|
9
8
|
*/
|
|
10
9
|
|
|
11
10
|
import { Type, type Static } from "@sinclair/typebox";
|
|
@@ -29,7 +28,7 @@ const SendGroupMessageParams = Type.Object({
|
|
|
29
28
|
}),
|
|
30
29
|
),
|
|
31
30
|
text: Type.String({
|
|
32
|
-
description: "
|
|
31
|
+
description: "要发送的消息内容(仅纯文本,不支持附件/文件)",
|
|
33
32
|
}),
|
|
34
33
|
atUserIds: Type.Optional(
|
|
35
34
|
Type.Array(Type.String(), {
|
|
@@ -41,19 +40,18 @@ const SendGroupMessageParams = Type.Object({
|
|
|
41
40
|
// ─── 工具构建函数 ─────────────────────────────────────────────────────────────
|
|
42
41
|
|
|
43
42
|
/**
|
|
44
|
-
*
|
|
45
|
-
* 由 index.ts 在 register()
|
|
46
|
-
* 工具执行时自动调用 getToken(config) 获取机器人 token。
|
|
43
|
+
* 构建「主动推送群消息」工具(纯文本)。
|
|
44
|
+
* 由 index.ts 在 register() 里调用。
|
|
47
45
|
*/
|
|
48
46
|
export function buildSendGroupMessageTool(config: XgImConfig): AnyAgentTool {
|
|
49
47
|
return {
|
|
50
48
|
name: "xg_cwork_im_send_group_message",
|
|
51
|
-
label: "【xg_cwork_im
|
|
49
|
+
label: "【xg_cwork_im】主动推送群消息(纯文本)",
|
|
52
50
|
description: [
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"groupId 为 IM
|
|
51
|
+
"【用途】仅用于 OpenClaw **主动推消息**:例如定时任务 / Cron / 后台任务完成后,经 xg_cwork_im 向群里发一条**纯文字**结果或通知。",
|
|
52
|
+
"【不要用错】用户正在 IM 里和助手**对话**时,**不要**用本工具回话——对话回复由 OpenClaw 正常走 channel,**用不上**本工具。",
|
|
53
|
+
"【发文件】本工具不能发附件。用户要「发报告/发文件」时,在**对用户的助手回复**里用 **mediaUrl(s)** 指向 workspace 或 file://;**禁止**为发文件上传到 tmpfiles 等外站。",
|
|
54
|
+
"【参数】groupId 为 IM 群唯一 ID;主动推送时若需告知某人,可填 atUserIds。",
|
|
57
55
|
].join("\n"),
|
|
58
56
|
parameters: SendGroupMessageParams,
|
|
59
57
|
async execute(_toolCallId: string, params: Static<typeof SendGroupMessageParams>) {
|
package/src/send-service.ts
CHANGED
|
@@ -60,6 +60,19 @@ async function postImMessage(
|
|
|
60
60
|
if (config.debug) {
|
|
61
61
|
log?.debug?.(`[cwork_im:send] Response body: ${JSON.stringify(res.data)}`);
|
|
62
62
|
}
|
|
63
|
+
|
|
64
|
+
// Detect business-level errors that arrive with HTTP 200.
|
|
65
|
+
// IM API unified response: { data, resultCode: 1, resultMsg: "" } — resultCode=1 means success.
|
|
66
|
+
const d = res.data;
|
|
67
|
+
if (d && typeof d === "object") {
|
|
68
|
+
const resultCode = (d as Record<string, unknown>).resultCode;
|
|
69
|
+
const resultMsg = (d as Record<string, unknown>).resultMsg;
|
|
70
|
+
if (resultCode !== undefined && resultCode !== 1) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`IM API error resultCode=${resultCode}${resultMsg ? ` resultMsg=${resultMsg}` : ""}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
63
76
|
} catch (err: unknown) {
|
|
64
77
|
const msg = err instanceof Error ? err.message : String(err);
|
|
65
78
|
log?.error(`[cwork_im:send] Failed to send message to groupId=${groupId}: ${msg}`);
|
|
@@ -141,21 +154,21 @@ export async function sendReplyDeliverBlock(
|
|
|
141
154
|
return;
|
|
142
155
|
} catch (err: unknown) {
|
|
143
156
|
log?.error(`[cwork_im:send] FILE deliver failed, fallback to text if any: ${formatDeliverError(err)}`);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
},
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
157
|
+
const errNote = `(文件发送失败:${formatDeliverError(err)})`;
|
|
158
|
+
const fallbackText = textToSend.length > 0 ? `${textToSend}\n\n${errNote}` : errNote;
|
|
159
|
+
await postImMessage(
|
|
160
|
+
config,
|
|
161
|
+
token,
|
|
162
|
+
groupId,
|
|
163
|
+
{
|
|
164
|
+
type: "RICH_TEXT",
|
|
165
|
+
text: fallbackText,
|
|
166
|
+
...(msgId ? { msgId } : {}),
|
|
167
|
+
...(reply ? { reply } : {}),
|
|
168
|
+
},
|
|
169
|
+
atUserIds,
|
|
170
|
+
log,
|
|
171
|
+
);
|
|
159
172
|
return;
|
|
160
173
|
}
|
|
161
174
|
}
|
package/src/types.ts
CHANGED
|
@@ -289,6 +289,7 @@ export type SendMessageBody =
|
|
|
289
289
|
toUserId?: string;
|
|
290
290
|
text: string;
|
|
291
291
|
atUserIds?: string[];
|
|
292
|
+
/** 流式占位消息 ID,用于将回复更新到对应的占位消息上 */
|
|
292
293
|
msgId?: string;
|
|
293
294
|
reply?: SendMessageReply;
|
|
294
295
|
}
|
|
@@ -299,6 +300,7 @@ export type SendMessageBody =
|
|
|
299
300
|
text: string;
|
|
300
301
|
attachments: SendMessageFileAttachment[];
|
|
301
302
|
atUserIds?: string[];
|
|
303
|
+
/** 流式占位消息 ID,用于将回复更新到对应的占位消息上 */
|
|
302
304
|
msgId?: string;
|
|
303
305
|
reply?: SendMessageReply;
|
|
304
306
|
};
|