Download src/flows/channel-setup.status.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 20.9 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/flows/channel-setup.status.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/flows/channel-setup.status.test.ts
-
curl -L -o channel-setup.status.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/flows/channel-setup.status.test.ts
20.9 kB
| // Channel setup status tests cover status text and docs link rendering. | |
| import { expectDefined } from "@openclaw/normalization-core/expect"; | |
| import { beforeEach, describe, expect, it, vi } from "vitest"; | |
| import { withEnv, withEnvAsync } from "../test-utils/env.js"; | |
| import { | |
| makeCatalogEntry, | |
| makeChannelSetupEntries, | |
| makeMeta, | |
| } from "./channel-setup.test-helpers.js"; | |
| type ListChatChannels = typeof import("../channels/chat-meta.js").listChatChannels; | |
| type ResolveChannelSetupEntries = | |
| typeof import("../commands/channel-setup/discovery.js").resolveChannelSetupEntries; | |
| type FormatChannelPrimerLine = typeof import("../channels/registry.js").formatChannelPrimerLine; | |
| type FormatChannelSelectionLine = | |
| typeof import("../channels/registry.js").formatChannelSelectionLine; | |
| type IsChannelConfigured = typeof import("../config/channel-configured.js").isChannelConfigured; | |
| type ChannelSetupPlugin = import("../channels/plugins/setup-wizard-types.js").ChannelSetupPlugin; | |
| type NoteChannelPrimerChannels = Parameters< | |
| typeof import("./channel-setup.status.js").noteChannelPrimer | |
| >[1]; | |
| const listChatChannels = vi.hoisted(() => vi.fn<ListChatChannels>(() => [])); | |
| const resolveChannelSetupEntries = vi.hoisted(() => | |
| vi.fn<ResolveChannelSetupEntries>(() => ({ | |
| entries: [], | |
| installedCatalogEntries: [], | |
| installableCatalogEntries: [], | |
| installedCatalogById: new Map(), | |
| installableCatalogById: new Map(), | |
| })), | |
| ); | |
| const formatChannelPrimerLine = vi.hoisted(() => | |
| vi.fn<FormatChannelPrimerLine>((meta) => `${meta.label}: ${meta.blurb}`), | |
| ); | |
| const formatChannelSelectionLine = vi.hoisted(() => | |
| vi.fn<FormatChannelSelectionLine>((meta) => `${meta.label} — ${meta.blurb}`), | |
| ); | |
| const isChannelConfigured = vi.hoisted(() => vi.fn<IsChannelConfigured>(() => false)); | |
| vi.mock("../channels/chat-meta.js", () => ({ | |
| listChatChannels: () => listChatChannels(), | |
| })); | |
| vi.mock("../channels/registry.js", () => ({ | |
| formatChannelPrimerLine: (meta: Parameters<FormatChannelPrimerLine>[0]) => | |
| formatChannelPrimerLine(meta), | |
| formatChannelSelectionLine: ( | |
| meta: Parameters<FormatChannelSelectionLine>[0], | |
| docsLink: Parameters<FormatChannelSelectionLine>[1], | |
| ) => formatChannelSelectionLine(meta, docsLink), | |
| normalizeAnyChannelId: (channelId?: string) => channelId?.trim().toLowerCase() ?? null, | |
| })); | |
| vi.mock("../commands/channel-setup/discovery.js", () => ({ | |
| resolveChannelSetupEntries: (params: Parameters<ResolveChannelSetupEntries>[0]) => | |
| resolveChannelSetupEntries(params), | |
| shouldShowChannelInSetup: (meta: { exposure?: { setup?: boolean } }) => | |
| meta.exposure?.setup !== false, | |
| })); | |
| vi.mock("../config/channel-configured.js", () => ({ | |
| isChannelConfigured: ( | |
| cfg: Parameters<IsChannelConfigured>[0], | |
| channelId: Parameters<IsChannelConfigured>[1], | |
| ) => isChannelConfigured(cfg, channelId), | |
| })); | |
| // Avoid touching the real `extensions/<id>` tree from unit tests. Status | |
| // rendering for installable catalog entries asks `bundled-sources` whether | |
| // a plugin already lives in-tree to decide between | |
| // "install plugin to enable" vs "bundled · enable to use". For these tests | |
| // we want the installable-catalog branch unconditionally, so we stub the | |
| // bundled lookup to "nothing is bundled". | |
| vi.mock("../plugins/bundled-sources.js", () => ({ | |
| resolveBundledPluginSources: () => new Map(), | |
| findBundledPluginSourceInMap: () => undefined, | |
| })); | |
| import { | |
| collectChannelStatus, | |
| noteChannelPrimer, | |
| noteChannelStatus, | |
| resolveChannelSelectionNoteLines, | |
| resolveChannelSetupSelectionContributions, | |
| } from "./channel-setup.status.js"; | |
| describe("resolveChannelSetupSelectionContributions", () => { | |
| beforeEach(() => { | |
| vi.clearAllMocks(); | |
| listChatChannels.mockReturnValue([ | |
| makeMeta("discord", "Discord"), | |
| makeMeta("imessage", "iMessage"), | |
| ]); | |
| resolveChannelSetupEntries.mockReturnValue(makeChannelSetupEntries()); | |
| formatChannelPrimerLine.mockImplementation( | |
| (meta: { label: string; blurb: string }) => `${meta.label}: ${meta.blurb}`, | |
| ); | |
| formatChannelSelectionLine.mockImplementation((meta) => `${meta.label} — ${meta.blurb}`); | |
| isChannelConfigured.mockReturnValue(false); | |
| }); | |
| it("uses the caller-selected workspace for explicit-fleet status and selection notes", async () => { | |
| const params = { | |
| cfg: { agents: { ownership: "explicit" as const, entries: { alpha: {}, beta: {} } } }, | |
| workspaceDir: "/tmp/beta-workspace", | |
| accountOverrides: {}, | |
| installedPlugins: [], | |
| selection: [], | |
| }; | |
| await collectChannelStatus(params); | |
| resolveChannelSelectionNoteLines(params); | |
| expect(resolveChannelSetupEntries).toHaveBeenCalledTimes(2); | |
| for (const [request] of resolveChannelSetupEntries.mock.calls) { | |
| expect(request.workspaceDir).toBe(params.workspaceDir); | |
| } | |
| }); | |
| it("uses the configured system agent workspace for explicit multi-agent setup", async () => { | |
| const cfg = { | |
| agents: { | |
| ownership: "explicit", | |
| defaults: { systemAgent: { agentId: "main" } }, | |
| entries: { | |
| main: { workspace: "/tmp/openclaw-main-workspace" }, | |
| helper: { workspace: "/tmp/openclaw-helper-workspace" }, | |
| third: { workspace: "/tmp/openclaw-third-workspace" }, | |
| }, | |
| }, | |
| } as const; | |
| await collectChannelStatus({ | |
| cfg, | |
| accountOverrides: {}, | |
| installedPlugins: [], | |
| }); | |
| resolveChannelSelectionNoteLines({ cfg, installedPlugins: [], selection: [] }); | |
| expect(resolveChannelSetupEntries).toHaveBeenNthCalledWith( | |
| 1, | |
| expect.objectContaining({ workspaceDir: "/tmp/openclaw-main-workspace" }), | |
| ); | |
| expect(resolveChannelSetupEntries).toHaveBeenNthCalledWith( | |
| 2, | |
| expect.objectContaining({ workspaceDir: "/tmp/openclaw-main-workspace" }), | |
| ); | |
| }); | |
| it("sorts channels alphabetically by picker label", () => { | |
| const contributions = resolveChannelSetupSelectionContributions({ | |
| entries: [ | |
| { | |
| id: "zalo", | |
| meta: { | |
| id: "zalo", | |
| label: "Zalo", | |
| selectionLabel: "Zalo (Bot API)", | |
| }, | |
| }, | |
| { | |
| id: "discord", | |
| meta: { | |
| id: "discord", | |
| label: "Discord", | |
| selectionLabel: "Discord (Bot API)", | |
| }, | |
| }, | |
| { | |
| id: "imessage", | |
| meta: { | |
| id: "imessage", | |
| label: "iMessage", | |
| selectionLabel: "iMessage (macOS app)", | |
| }, | |
| }, | |
| ], | |
| statusByChannel: new Map(), | |
| resolveDisabledHint: () => undefined, | |
| }); | |
| expect(contributions.map((contribution) => contribution.option.label)).toEqual([ | |
| "Discord (Bot API)", | |
| "iMessage (macOS app)", | |
| "Zalo (Bot API)", | |
| ]); | |
| }); | |
| it("does not invent hints before status has been collected", () => { | |
| const contributions = resolveChannelSetupSelectionContributions({ | |
| entries: [ | |
| { | |
| id: "zalo", | |
| meta: { | |
| id: "zalo", | |
| label: "Zalo", | |
| selectionLabel: "Zalo (Bot API)", | |
| }, | |
| }, | |
| ], | |
| statusByChannel: new Map(), | |
| resolveDisabledHint: () => undefined, | |
| }); | |
| expect(contributions.map((contribution) => contribution.option)).toEqual([ | |
| { | |
| value: "zalo", | |
| label: "Zalo (Bot API)", | |
| }, | |
| ]); | |
| }); | |
| it("combines real status and disabled hints when available", () => { | |
| const contributions = resolveChannelSetupSelectionContributions({ | |
| entries: [ | |
| { | |
| id: "zalo", | |
| meta: { | |
| id: "zalo", | |
| label: "Zalo", | |
| selectionLabel: "Zalo (Bot API)", | |
| }, | |
| }, | |
| ], | |
| statusByChannel: new Map([["zalo", { selectionHint: "configured" }]]), | |
| resolveDisabledHint: () => "disabled", | |
| }); | |
| expect(contributions[0]?.option).toEqual({ | |
| value: "zalo", | |
| label: "Zalo (Bot API)", | |
| hint: "configured · disabled", | |
| }); | |
| }); | |
| it("sanitizes picker labels and hints before terminal rendering", () => { | |
| const contributions = resolveChannelSetupSelectionContributions({ | |
| entries: [ | |
| { | |
| id: "zalo", | |
| meta: { | |
| id: "zalo", | |
| label: "Zalo\u001B[31m\nBot\u0007", | |
| }, | |
| }, | |
| ], | |
| statusByChannel: new Map([["zalo", { selectionHint: "configured\u001B[2K\nnow" }]]), | |
| resolveDisabledHint: () => "disabled\u0007", | |
| }); | |
| expect(contributions[0]?.option).toEqual({ | |
| value: "zalo", | |
| label: "Zalo\\nBot", | |
| hint: "configured\\nnow · disabled", | |
| }); | |
| }); | |
| it("sanitizes the picker fallback label when metadata sanitizes to empty", () => { | |
| const contributions = resolveChannelSetupSelectionContributions({ | |
| entries: [ | |
| { | |
| id: "bad\u001B[31m\nid", | |
| meta: { | |
| id: "bad\u001B[31m\nid", | |
| label: "\u001B[31m\u0007", | |
| }, | |
| }, | |
| ], | |
| statusByChannel: new Map(), | |
| resolveDisabledHint: () => undefined, | |
| }); | |
| expect(contributions[0]?.option).toEqual({ | |
| value: "bad\u001B[31m\nid", | |
| label: "bad\\nid", | |
| }); | |
| }); | |
| it("sanitizes channel labels in status note lines", async () => { | |
| listChatChannels.mockReturnValue([makeMeta("discord", "Discord\u001B[31m\nCore\u0007")]); | |
| resolveChannelSetupEntries.mockReturnValue( | |
| makeChannelSetupEntries({ | |
| installedCatalogEntries: [makeCatalogEntry("matrix", "Matrix\u001B[2K\nPlugin\u0007")], | |
| installableCatalogEntries: [makeCatalogEntry("zalo", "Zalo\u001B[2K\nPlugin\u0007")], | |
| }), | |
| ); | |
| const summary = await collectChannelStatus({ | |
| cfg: {} as never, | |
| accountOverrides: {}, | |
| installedPlugins: [], | |
| }); | |
| expect(summary.statusLines).toEqual([ | |
| "Discord\\nCore: not configured", | |
| "Matrix\\nPlugin: installed", | |
| "Zalo\\nPlugin: install plugin to enable", | |
| ]); | |
| }); | |
| it.each(["rejected status check", "synchronous status check", "adapter resolution"] as const)( | |
| "keeps healthy channels selectable after a %s failure", | |
| async (failurePoint) => { | |
| const installedPlugins = [ | |
| { | |
| id: "matrix", | |
| meta: makeMeta("matrix", "Matrix"), | |
| capabilities: { chatTypes: [] }, | |
| config: {} as ChannelSetupPlugin["config"], | |
| }, | |
| { | |
| id: "telegram", | |
| meta: makeMeta("telegram", "Telegram"), | |
| capabilities: { chatTypes: [] }, | |
| config: {} as ChannelSetupPlugin["config"], | |
| }, | |
| ] satisfies ChannelSetupPlugin[]; | |
| listChatChannels.mockReturnValue([ | |
| makeMeta("matrix", "Matrix"), | |
| makeMeta("telegram", "Telegram"), | |
| ]); | |
| isChannelConfigured.mockImplementation((_, channelId) => channelId === "matrix"); | |
| const failure = new Error("lazy Matrix setup module unavailable"); | |
| const summary = await collectChannelStatus({ | |
| cfg: {} as never, | |
| accountOverrides: {}, | |
| installedPlugins, | |
| resolveAdapter: (channel) => { | |
| if (channel === "matrix" && failurePoint === "adapter resolution") { | |
| throw failure; | |
| } | |
| return { | |
| channel, | |
| getStatus: | |
| channel === "matrix" | |
| ? failurePoint === "synchronous status check" | |
| ? () => { | |
| throw failure; | |
| } | |
| : async () => { | |
| throw failure; | |
| } | |
| : async () => ({ | |
| channel: "telegram", | |
| configured: true, | |
| statusLines: ["Telegram: configured"], | |
| selectionHint: "configured", | |
| quickstartScore: 5, | |
| }), | |
| } as never; | |
| }, | |
| }); | |
| expect(summary.statusByChannel.get("matrix")).toEqual({ | |
| channel: "matrix", | |
| configured: true, | |
| statusLines: ["Matrix: status unavailable (lazy Matrix setup module unavailable)"], | |
| selectionHint: "status unavailable", | |
| }); | |
| expect(summary.statusByChannel.get("telegram")).toEqual({ | |
| channel: "telegram", | |
| configured: true, | |
| statusLines: ["Telegram: configured"], | |
| selectionHint: "configured", | |
| quickstartScore: 5, | |
| }); | |
| expect(summary.statusLines).toEqual([ | |
| "Matrix: status unavailable (lazy Matrix setup module unavailable)", | |
| "Telegram: configured", | |
| ]); | |
| }, | |
| ); | |
| it("redacts credentials and terminal controls in failed channel status checks", async () => { | |
| const token = "sk-abcdefghijklmnopqrstuv"; | |
| const summary = await collectChannelStatus({ | |
| cfg: {} as never, | |
| accountOverrides: {}, | |
| installedPlugins: [ | |
| { | |
| id: "matrix", | |
| meta: makeMeta("matrix", "Matrix"), | |
| capabilities: { chatTypes: [] }, | |
| config: {} as ChannelSetupPlugin["config"], | |
| }, | |
| ], | |
| resolveAdapter: (channel) => | |
| ({ | |
| channel, | |
| getStatus: async () => { | |
| throw new Error(`\u001B[31mloader failed\nAuthorization: Bearer ${token}`); | |
| }, | |
| }) as never, | |
| }); | |
| const statusLine = summary.statusLines[0]; | |
| expect(statusLine).toContain( | |
| "Matrix: status unavailable (loader failed\\nAuthorization: Bearer", | |
| ); | |
| expect(statusLine).not.toContain(token); | |
| expect(statusLine).not.toContain("\u001B"); | |
| expect(statusLine).not.toContain("\n"); | |
| }); | |
| it("localizes channel status note labels", async () => { | |
| listChatChannels.mockReturnValue([ | |
| makeMeta("discord", "Discord"), | |
| makeMeta("telegram", "Telegram"), | |
| ]); | |
| isChannelConfigured.mockImplementation((_, channelId) => channelId === "discord"); | |
| resolveChannelSetupEntries.mockReturnValue( | |
| makeChannelSetupEntries({ | |
| installedCatalogEntries: [makeCatalogEntry("matrix", "Matrix")], | |
| installableCatalogEntries: [makeCatalogEntry("zalo", "Zalo")], | |
| }), | |
| ); | |
| await withEnvAsync({ OPENCLAW_LOCALE: "zh-CN" }, async () => { | |
| const summary = await collectChannelStatus({ | |
| cfg: {} as never, | |
| accountOverrides: {}, | |
| installedPlugins: [], | |
| }); | |
| expect(summary.statusLines).toEqual([ | |
| "Discord: 已配置(插件已禁用)", | |
| "Telegram: 未配置", | |
| "Matrix: 已安装", | |
| "Zalo: 安装插件后启用", | |
| ]); | |
| }); | |
| }); | |
| it("localizes channel status note title", async () => { | |
| const note = vi.fn(async () => {}); | |
| listChatChannels.mockReturnValue([makeMeta("discord", "Discord")]); | |
| isChannelConfigured.mockReturnValue(true); | |
| await withEnvAsync({ OPENCLAW_LOCALE: "zh-CN" }, async () => { | |
| await noteChannelStatus({ | |
| cfg: {} as never, | |
| prompter: { note } as never, | |
| installedPlugins: [], | |
| }); | |
| expect(note).toHaveBeenCalledWith(expect.any(String), "频道状态"); | |
| }); | |
| }); | |
| it("sanitizes channel metadata before primer notes", async () => { | |
| const note = vi.fn(async () => undefined); | |
| await noteChannelPrimer( | |
| { note } as never, | |
| [ | |
| { | |
| id: "bad\u001B[31m\nid", | |
| label: "\u001B[31m\u0007", | |
| blurb: "Blurb\u001B[2K\nline\u0007", | |
| } satisfies NoteChannelPrimerChannels[number], | |
| ] as NoteChannelPrimerChannels, | |
| ); | |
| expect(formatChannelPrimerLine).toHaveBeenCalledOnce(); | |
| const [primerMeta] = expectDefined( | |
| formatChannelPrimerLine.mock.calls.at(0), | |
| "primer line call", | |
| ); | |
| expect(primerMeta?.id).toBe("bad\\nid"); | |
| expect(primerMeta?.label).toBe("bad\\nid"); | |
| expect(primerMeta?.selectionLabel).toBe("bad\\nid"); | |
| expect(primerMeta?.blurb).toBe("Blurb\\nline"); | |
| expect(note).toHaveBeenCalledWith( | |
| [ | |
| "Inbound DM safety defaults to pairing: unknown senders get a pairing code first.", | |
| "Approve with: openclaw pairing approve <channel> <code>", | |
| 'Open/public DMs require dmPolicy="open" plus allowFrom=["*"].', | |
| 'For multi-user DMs, isolate sessions with: openclaw config set session.dmScope "per-channel-peer" (or "per-account-channel-peer" for multi-account channels).', | |
| "Docs: https://docs.openclaw.ai/channels/pairing", | |
| "", | |
| "bad\\nid: Blurb\\nline", | |
| ].join("\n"), | |
| "How channels work", | |
| ); | |
| }); | |
| it("localizes built-in channel primer copy", async () => { | |
| const note = vi.fn(async () => undefined); | |
| await withEnvAsync({ OPENCLAW_LOCALE: "zh-CN" }, async () => { | |
| await noteChannelPrimer( | |
| { note } as never, | |
| [ | |
| { | |
| id: "discord", | |
| label: "Discord", | |
| blurb: "very well supported right now.", | |
| } satisfies NoteChannelPrimerChannels[number], | |
| ] as NoteChannelPrimerChannels, | |
| ); | |
| }); | |
| expect(formatChannelPrimerLine).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| label: "Discord", | |
| blurb: "目前支持很完善。", | |
| }), | |
| ); | |
| expect(note).toHaveBeenCalledWith( | |
| expect.stringContaining("入站 DM 安全默认使用配对"), | |
| "频道工作方式", | |
| ); | |
| }); | |
| it("sanitizes channel metadata before selection notes", () => { | |
| resolveChannelSetupEntries.mockReturnValue( | |
| makeChannelSetupEntries({ | |
| entries: [ | |
| { | |
| id: "zalo", | |
| meta: { | |
| id: "zalo", | |
| label: "Zalo\u001B[31m\nBot\u0007", | |
| selectionLabel: "Zalo", | |
| docsPath: "/channels/zalo", | |
| docsLabel: "Docs\u001B[2K\nLabel", | |
| blurb: "Setup\u001B[2K\nhelp\u0007", | |
| selectionDocsPrefix: "Docs\u001B[2K\nPrefix", | |
| selectionExtras: ["Extra\u001B[2K\nOne", "\u001B[31m\u0007"], | |
| }, | |
| }, | |
| ], | |
| }), | |
| ); | |
| const lines = resolveChannelSelectionNoteLines({ | |
| cfg: {} as never, | |
| installedPlugins: [], | |
| selection: ["zalo"], | |
| }); | |
| expect(formatChannelSelectionLine).toHaveBeenCalledOnce(); | |
| const [selectionMeta, docsLink] = expectDefined( | |
| formatChannelSelectionLine.mock.calls.at(0), | |
| "selection line call", | |
| ); | |
| expect(selectionMeta?.label).toBe("Zalo\\nBot"); | |
| expect(selectionMeta?.blurb).toBe("Setup\\nhelp"); | |
| expect(selectionMeta?.docsLabel).toBe("Docs\\nLabel"); | |
| expect(selectionMeta?.selectionDocsPrefix).toBe("Docs\\nPrefix"); | |
| expect(selectionMeta?.selectionExtras).toEqual(["Extra\\nOne"]); | |
| if (typeof docsLink !== "function") { | |
| throw new Error("Expected docs link formatter"); | |
| } | |
| expect(docsLink("/channels/zalo", "Docs")).toBe("https://docs.openclaw.ai/channels/zalo"); | |
| expect(lines).toEqual(["Zalo\\nBot — Setup\\nhelp"]); | |
| }); | |
| it.each([ | |
| ["empty", "", ""], | |
| ["whitespace", " \t ", "Docs:"], | |
| ["control-only", "\u001B[2K\u0007", "Docs:"], | |
| ] as const)("normalizes %s selection docs prefixes", (_label, prefix, expected) => { | |
| resolveChannelSetupEntries.mockReturnValue( | |
| makeChannelSetupEntries({ | |
| entries: [ | |
| { | |
| id: "custom-chat", | |
| meta: { | |
| id: "custom-chat", | |
| label: "Custom Chat", | |
| selectionLabel: "Custom Chat", | |
| docsPath: "/channels/custom-chat", | |
| blurb: "External channel.", | |
| selectionDocsPrefix: prefix, | |
| }, | |
| }, | |
| ], | |
| }), | |
| ); | |
| resolveChannelSelectionNoteLines({ | |
| cfg: {} as never, | |
| installedPlugins: [], | |
| selection: ["custom-chat"], | |
| }); | |
| const [selectionMeta] = expectDefined( | |
| formatChannelSelectionLine.mock.calls.at(0), | |
| "selection line call", | |
| ); | |
| expect(selectionMeta?.selectionDocsPrefix).toBe(expected); | |
| }); | |
| it("localizes built-in channel blurbs before selection notes", () => { | |
| resolveChannelSetupEntries.mockReturnValue( | |
| makeChannelSetupEntries({ | |
| entries: [ | |
| { | |
| id: "feishu", | |
| meta: { | |
| id: "feishu", | |
| label: "Feishu", | |
| selectionLabel: "Feishu", | |
| docsPath: "/channels/feishu", | |
| docsLabel: "feishu", | |
| blurb: "飞书/Lark enterprise messaging.", | |
| }, | |
| }, | |
| ], | |
| }), | |
| ); | |
| withEnv({ OPENCLAW_LOCALE: "zh-CN" }, () => { | |
| const lines = resolveChannelSelectionNoteLines({ | |
| cfg: {} as never, | |
| installedPlugins: [], | |
| selection: ["feishu"], | |
| }); | |
| expect(formatChannelSelectionLine).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| label: "Feishu", | |
| blurb: "飞书/Lark 企业消息。", | |
| selectionDocsPrefix: "文档:", | |
| }), | |
| expect.any(Function), | |
| ); | |
| expect(lines).toEqual(["Feishu — 飞书/Lark 企业消息。"]); | |
| }); | |
| }); | |
| }); | |