























@@ -0,0 +1,200 @@
1+import { readFile, writeFile } from "node:fs/promises";
2+import path from "node:path";
3+import type { MacosGuest } from "./guest-transports.ts";
4+import { run, say, shellQuote, warn } from "./host-command.ts";
5+6+export type DiscordSmokePhase = "fresh" | "upgrade";
7+8+export interface MacosDiscordConfig {
9+channelId: string;
10+guildId: string;
11+token: string;
12+}
13+14+export class MacosDiscordSmoke {
15+constructor(
16+private input: {
17+config: MacosDiscordConfig;
18+guest: MacosGuest;
19+guestNode: string;
20+guestOpenClaw: string;
21+guestOpenClawEntry: string;
22+runDir: string;
23+vmName: string;
24+},
25+) {}
26+27+configure(): void {
28+const guilds = JSON.stringify({
29+[this.input.config.guildId]: {
30+channels: {
31+[this.input.config.channelId]: {
32+enabled: true,
33+requireMention: false,
34+},
35+},
36+},
37+});
38+this.input.guest.sh(`set -eu
39+${this.input.guestNode} ${this.input.guestOpenClawEntry} config set channels.discord.token ${shellQuote(this.input.config.token)}
40+${this.input.guestNode} ${this.input.guestOpenClawEntry} config set channels.discord.enabled true
41+${this.input.guestNode} ${this.input.guestOpenClawEntry} config set channels.discord.groupPolicy allowlist
42+${this.input.guestNode} ${this.input.guestOpenClawEntry} config set channels.discord.guilds ${shellQuote(guilds)} --strict-json
43+${this.input.guestNode} ${this.input.guestOpenClawEntry} gateway restart
44+${this.input.guestNode} ${this.input.guestOpenClawEntry} channels status --probe --json`);
45+}
46+47+async runRoundtrip(phase: DiscordSmokePhase): Promise<void> {
48+const nonce = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
49+const outboundNonce = `${phase}-out-${nonce}`;
50+const inboundNonce = `${phase}-in-${nonce}`;
51+const outboundLog = path.join(this.input.runDir, `${phase}.discord-send.json`);
52+const sentIdFile = path.join(this.input.runDir, `${phase}.discord-sent-message-id`);
53+const hostIdFile = path.join(this.input.runDir, `${phase}.discord-host-message-id`);
54+const outbound = this.input.guest.exec([
55+this.input.guestOpenClaw,
56+"message",
57+"send",
58+"--channel",
59+"discord",
60+"--target",
61+`channel:${this.input.config.channelId}`,
62+"--message",
63+`parallels-macos-smoke-outbound-${outboundNonce}`,
64+"--silent",
65+"--json",
66+]);
67+await writeFile(outboundLog, `${outbound}\n`, "utf8");
68+const sentId = this.discordMessageId(outbound);
69+await writeFile(sentIdFile, `${sentId}\n`, "utf8");
70+await this.waitForHostVisibility(outboundNonce, sentId);
71+const hostId = await this.postDiscordMessage(`parallels-macos-smoke-inbound-${inboundNonce}`);
72+await writeFile(hostIdFile, `${hostId}\n`, "utf8");
73+this.waitForGuestReadback(inboundNonce);
74+}
75+76+async cleanupMessages(): Promise<void> {
77+for (const name of [
78+"fresh.discord-sent-message-id",
79+"fresh.discord-host-message-id",
80+"upgrade.discord-sent-message-id",
81+"upgrade.discord-host-message-id",
82+]) {
83+const filePath = path.join(this.input.runDir, name);
84+const id = await readFile(filePath, "utf8").catch(() => "");
85+if (id.trim()) {
86+await this.discordApi(
87+"DELETE",
88+`/channels/${this.input.config.channelId}/messages/${id.trim()}`,
89+).catch(() => "");
90+}
91+}
92+}
93+94+stopVmAfterSuccessfulSmoke(freshDiscord: string, upgradeDiscord: string): void {
95+if (freshDiscord !== "pass" && upgradeDiscord !== "pass") {
96+return;
97+}
98+say(`Stop ${this.input.vmName} after successful Discord smoke`);
99+const result = run("prlctl", ["stop", this.input.vmName], {
100+check: false,
101+quiet: true,
102+timeoutMs: 120_000,
103+});
104+if (result.status !== 0) {
105+warn(
106+`failed to stop ${this.input.vmName} after successful Discord smoke (rc=${result.status})`,
107+);
108+}
109+}
110+111+private discordMessageId(payloadText: string): string {
112+const payload = JSON.parse(payloadText) as {
113+payload?: { messageId?: string; result?: { messageId?: string } };
114+};
115+const id = payload.payload?.messageId || payload.payload?.result?.messageId;
116+if (!id) {
117+throw new Error("messageId missing from send output");
118+}
119+return id;
120+}
121+122+private async discordApi(method: string, apiPath: string, payload?: unknown): Promise<string> {
123+const args = [
124+"-fsS",
125+"-X",
126+method,
127+"-H",
128+`Authorization: Bot ${this.input.config.token}`,
129+ ...(payload == null
130+ ? []
131+ : ["-H", "Content-Type: application/json", "--data", JSON.stringify(payload)]),
132+`https://discord.com/api/v10${apiPath}`,
133+];
134+return run("curl", args, { quiet: true }).stdout;
135+}
136+137+private async waitForHostVisibility(nonce: string, messageId: string): Promise<void> {
138+const deadline = Date.now() + 180_000;
139+while (Date.now() < deadline) {
140+const direct = await this.discordApi(
141+"GET",
142+`/channels/${this.input.config.channelId}/messages/${messageId}`,
143+).catch(() => "");
144+if (direct.includes(nonce)) {
145+return;
146+}
147+const recent = await this.discordApi(
148+"GET",
149+`/channels/${this.input.config.channelId}/messages?limit=20`,
150+).catch(() => "");
151+if (recent.includes(nonce)) {
152+return;
153+}
154+run("sleep", ["2"], { quiet: true });
155+}
156+throw new Error("Discord host visibility timed out");
157+}
158+159+private async postDiscordMessage(content: string): Promise<string> {
160+const response = await this.discordApi(
161+"POST",
162+`/channels/${this.input.config.channelId}/messages`,
163+{
164+ content,
165+flags: 4096,
166+},
167+);
168+const id = (JSON.parse(response) as { id?: string }).id;
169+if (!id) {
170+throw new Error("host Discord post missing message id");
171+}
172+return id;
173+}
174+175+private waitForGuestReadback(nonce: string): void {
176+const deadline = Date.now() + 180_000;
177+while (Date.now() < deadline) {
178+const result = this.input.guest.run(
179+[
180+this.input.guestOpenClaw,
181+"message",
182+"read",
183+"--channel",
184+"discord",
185+"--target",
186+`channel:${this.input.config.channelId}`,
187+"--limit",
188+"20",
189+"--json",
190+],
191+{ check: false },
192+);
193+if (result.status === 0 && result.stdout.includes(nonce)) {
194+return;
195+}
196+run("sleep", ["3"], { quiet: true });
197+}
198+throw new Error("Discord guest readback timed out");
199+}
200+}
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。