refactor: share passive account lifecycle helpers

This commit is contained in:
Peter Steinberger
2026-03-10 20:41:06 +00:00
parent 50ded5052f
commit a455c0cc3d
14 changed files with 269 additions and 55 deletions

View File

@@ -21,6 +21,7 @@ import {
import {
buildAccountScopedDmSecurityPolicy,
collectOpenGroupPolicyRestrictSendersWarnings,
createAccountStatusSink,
formatNormalizedAllowFromEntries,
mapAllowFromEntries,
} from "openclaw/plugin-sdk/compat";
@@ -369,8 +370,11 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
startAccount: async (ctx) => {
const account = ctx.account;
const webhookPath = resolveWebhookPathFromConfig(account.config);
ctx.setStatus({
accountId: account.accountId,
const statusSink = createAccountStatusSink({
accountId: ctx.accountId,
setStatus: ctx.setStatus,
});
statusSink({
baseUrl: account.baseUrl,
});
ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`);
@@ -379,7 +383,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
statusSink,
webhookPath,
});
},

View File

@@ -12,6 +12,7 @@ import {
buildComputedAccountStatusSnapshot,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
createAccountStatusSink,
getChatChannelMeta,
listDirectoryGroupEntriesFromMapKeys,
listDirectoryUserEntriesFromAllowFrom,
@@ -21,6 +22,7 @@ import {
PAIRING_APPROVED_MESSAGE,
resolveChannelMediaMaxBytes,
resolveGoogleChatGroupRequireMention,
runPassiveAccountLifecycle,
type ChannelDock,
type ChannelMessageActionAdapter,
type ChannelPlugin,
@@ -509,37 +511,39 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
ctx.log?.info(`[${account.accountId}] starting Google Chat webhook`);
ctx.setStatus({
const statusSink = createAccountStatusSink({
accountId: account.accountId,
setStatus: ctx.setStatus,
});
ctx.log?.info(`[${account.accountId}] starting Google Chat webhook`);
statusSink({
running: true,
lastStartAt: Date.now(),
webhookPath: resolveGoogleChatWebhookPath({ account }),
audienceType: account.config.audienceType,
audience: account.config.audience,
});
const unregister = await startGoogleChatMonitor({
account,
config: ctx.cfg,
runtime: ctx.runtime,
await runPassiveAccountLifecycle({
abortSignal: ctx.abortSignal,
webhookPath: account.config.webhookPath,
webhookUrl: account.config.webhookUrl,
statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }),
});
// Keep the promise pending until abort (webhook mode is passive).
await new Promise<void>((resolve) => {
if (ctx.abortSignal.aborted) {
resolve();
return;
}
ctx.abortSignal.addEventListener("abort", () => resolve(), { once: true });
});
unregister?.();
ctx.setStatus({
accountId: account.accountId,
running: false,
lastStopAt: Date.now(),
start: async () =>
await startGoogleChatMonitor({
account,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
webhookPath: account.config.webhookPath,
webhookUrl: account.config.webhookUrl,
statusSink,
}),
stop: async (unregister) => {
unregister?.();
},
onStop: async () => {
statusSink({
running: false,
lastStopAt: Date.now(),
});
},
});
},
},

View File

@@ -0,0 +1,67 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createStartAccountContext } from "../../test-utils/start-account-context.js";
import type { ResolvedIrcAccount } from "./accounts.js";
const hoisted = vi.hoisted(() => ({
monitorIrcProvider: vi.fn(),
}));
vi.mock("./monitor.js", async () => {
const actual = await vi.importActual<typeof import("./monitor.js")>("./monitor.js");
return {
...actual,
monitorIrcProvider: hoisted.monitorIrcProvider,
};
});
import { ircPlugin } from "./channel.js";
describe("ircPlugin gateway.startAccount", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("keeps startAccount pending until abort, then stops the monitor", async () => {
const stop = vi.fn();
hoisted.monitorIrcProvider.mockResolvedValue({ stop });
const account: ResolvedIrcAccount = {
accountId: "default",
enabled: true,
name: "default",
configured: true,
host: "irc.example.com",
port: 6697,
tls: true,
nick: "openclaw",
username: "openclaw",
realname: "OpenClaw",
password: "",
passwordSource: "none",
config: {} as ResolvedIrcAccount["config"],
};
const abort = new AbortController();
const task = ircPlugin.gateway!.startAccount!(
createStartAccountContext({
account,
abortSignal: abort.signal,
}),
);
let settled = false;
void task.then(() => {
settled = true;
});
await vi.waitFor(() => {
expect(hoisted.monitorIrcProvider).toHaveBeenCalledOnce();
});
expect(settled).toBe(false);
expect(stop).not.toHaveBeenCalled();
abort.abort();
await task;
expect(stop).toHaveBeenCalledOnce();
});
});

View File

@@ -9,10 +9,12 @@ import {
buildBaseAccountStatusSnapshot,
buildBaseChannelStatusSummary,
buildChannelConfigSchema,
createAccountStatusSink,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
getChatChannelMeta,
PAIRING_APPROVED_MESSAGE,
runPassiveAccountLifecycle,
setAccountEnabledInConfigSection,
type ChannelPlugin,
} from "openclaw/plugin-sdk/irc";
@@ -353,6 +355,10 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
const statusSink = createAccountStatusSink({
accountId: ctx.accountId,
setStatus: ctx.setStatus,
});
if (!account.configured) {
throw new Error(
`IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`,
@@ -361,14 +367,20 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
ctx.log?.info(
`[${account.accountId}] starting IRC provider (${account.host}:${account.port}${account.tls ? " tls" : ""})`,
);
const { stop } = await monitorIrcProvider({
accountId: account.accountId,
config: ctx.cfg as CoreConfig,
runtime: ctx.runtime,
await runPassiveAccountLifecycle({
abortSignal: ctx.abortSignal,
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
start: async () =>
await monitorIrcProvider({
accountId: account.accountId,
config: ctx.cfg as CoreConfig,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
statusSink,
}),
stop: async (monitor) => {
monitor.stop();
},
});
return { stop };
},
},
};

View File

@@ -9,6 +9,7 @@ import {
applySetupAccountConfigPatch,
buildComputedAccountStatusSnapshot,
buildChannelConfigSchema,
createAccountStatusSink,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
migrateBaseNameToDefaultAccount,
@@ -500,8 +501,11 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
ctx.setStatus({
accountId: account.accountId,
const statusSink = createAccountStatusSink({
accountId: ctx.accountId,
setStatus: ctx.setStatus,
});
statusSink({
baseUrl: account.baseUrl,
botTokenSource: account.botTokenSource,
});
@@ -513,7 +517,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
statusSink,
});
},
},

View File

@@ -2,8 +2,10 @@ import {
buildAccountScopedDmSecurityPolicy,
collectAllowlistProviderGroupPolicyWarnings,
collectOpenGroupPolicyRouteAllowlistWarnings,
createAccountStatusSink,
formatAllowFromLowercase,
mapAllowFromEntries,
runPassiveAccountLifecycle,
} from "openclaw/plugin-sdk/compat";
import {
applyAccountNameToChannelSection,
@@ -15,7 +17,6 @@ import {
deleteAccountFromConfigSection,
normalizeAccountId,
setAccountEnabledInConfigSection,
waitForAbortSignal,
type ChannelPlugin,
type OpenClawConfig,
type ChannelSetupInput,
@@ -338,17 +339,25 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
ctx.log?.info(`[${account.accountId}] starting Nextcloud Talk webhook server`);
const { stop } = await monitorNextcloudTalkProvider({
accountId: account.accountId,
config: ctx.cfg as CoreConfig,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
const statusSink = createAccountStatusSink({
accountId: ctx.accountId,
setStatus: ctx.setStatus,
});
// Keep webhook channels pending for the account lifecycle.
await waitForAbortSignal(ctx.abortSignal);
stop();
await runPassiveAccountLifecycle({
abortSignal: ctx.abortSignal,
start: async () =>
await monitorNextcloudTalkProvider({
accountId: account.accountId,
config: ctx.cfg as CoreConfig,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
statusSink,
}),
stop: async (monitor) => {
monitor.stop();
},
});
},
logoutAccount: async ({ accountId, cfg }) => {
const nextCfg = { ...cfg } as OpenClawConfig;

View File

@@ -1,8 +1,9 @@
import {
buildAccountScopedDmSecurityPolicy,
collectOpenProviderGroupPolicyWarnings,
buildOpenGroupPolicyRestrictSendersWarning,
buildOpenGroupPolicyWarning,
collectOpenProviderGroupPolicyWarnings,
createAccountStatusSink,
mapAllowFromEntries,
} from "openclaw/plugin-sdk/compat";
import type {
@@ -357,6 +358,10 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
`[${account.accountId}] Zalo probe threw before provider start: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
);
}
const statusSink = createAccountStatusSink({
accountId: ctx.accountId,
setStatus: ctx.setStatus,
});
ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel} mode=${mode}`);
const { monitorZaloProvider } = await import("./monitor.js");
return monitorZaloProvider({
@@ -370,7 +375,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
webhookSecret: normalizeSecretInputString(account.config.webhookSecret),
webhookPath: account.config.webhookPath,
fetcher,
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
statusSink,
});
},
},

View File

@@ -1,5 +1,6 @@
import {
buildAccountScopedDmSecurityPolicy,
createAccountStatusSink,
mapAllowFromEntries,
} from "openclaw/plugin-sdk/compat";
import type {
@@ -682,6 +683,10 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
} catch {
// ignore probe errors
}
const statusSink = createAccountStatusSink({
accountId: ctx.accountId,
setStatus: ctx.setStatus,
});
ctx.log?.info(`[${account.accountId}] starting zalouser provider${userLabel}`);
const { monitorZalouserProvider } = await import("./monitor.js");
return monitorZalouserProvider({
@@ -689,7 +694,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
statusSink,
});
},
loginWithQrStart: async (params) => {