Plugins: broaden plugin surface for Codex App Server (#45318)

* Plugins: add inbound claim and Telegram interaction seams

* Plugins: add Discord interaction surface

* Chore: fix formatting after plugin rebase

* fix(hooks): preserve observers after inbound claim

* test(hooks): cover claimed inbound observer delivery

* fix(plugins): harden typing lease refreshes

* fix(discord): pass real auth to plugin interactions

* fix(plugins): remove raw session binding runtime exposure

* fix(plugins): tighten interactive callback handling

* Plugins: gate conversation binding with approvals

* Plugins: migrate legacy plugin binding records

* Plugins/phone-control: update test command context

* Plugins: migrate legacy binding ids

* Plugins: migrate legacy codex session bindings

* Discord: fix plugin interaction handling

* Discord: support direct plugin conversation binds

* Plugins: preserve Discord command bind targets

* Tests: fix plugin binding and interactive fallout

* Discord: stabilize directory lookup tests

* Discord: route bound DMs to plugins

* Discord: restore plugin bindings after restart

* Telegram: persist detached plugin bindings

* Plugins: limit binding APIs to Telegram and Discord

* Plugins: harden bound conversation routing

* Plugins: fix extension target imports

* Plugins: fix Telegram runtime extension imports

* Plugins: format rebased binding handlers

* Discord: bind group DM interactions by channel

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Harold Hunt
2026-03-15 19:06:11 -04:00
committed by GitHub
parent 4eee827dce
commit aa1454d1a8
53 changed files with 5322 additions and 123 deletions

View File

@@ -7,7 +7,18 @@ import { monitorDiscordProvider } from "../../../extensions/discord/src/monitor.
import { probeDiscord } from "../../../extensions/discord/src/probe.js";
import { resolveDiscordChannelAllowlist } from "../../../extensions/discord/src/resolve-channels.js";
import { resolveDiscordUserAllowlist } from "../../../extensions/discord/src/resolve-users.js";
import { sendMessageDiscord, sendPollDiscord } from "../../../extensions/discord/src/send.js";
import {
createThreadDiscord,
deleteMessageDiscord,
editChannelDiscord,
editMessageDiscord,
pinMessageDiscord,
sendDiscordComponentMessage,
sendMessageDiscord,
sendPollDiscord,
sendTypingDiscord,
unpinMessageDiscord,
} from "../../../extensions/discord/src/send.js";
import { monitorIMessageProvider } from "../../../extensions/imessage/src/monitor.js";
import { probeIMessage } from "../../../extensions/imessage/src/probe.js";
import { sendMessageIMessage } from "../../../extensions/imessage/src/send.js";
@@ -29,7 +40,17 @@ import {
} from "../../../extensions/telegram/src/audit.js";
import { monitorTelegramProvider } from "../../../extensions/telegram/src/monitor.js";
import { probeTelegram } from "../../../extensions/telegram/src/probe.js";
import { sendMessageTelegram, sendPollTelegram } from "../../../extensions/telegram/src/send.js";
import {
deleteMessageTelegram,
editMessageReplyMarkupTelegram,
editMessageTelegram,
pinMessageTelegram,
renameForumTopicTelegram,
sendMessageTelegram,
sendPollTelegram,
sendTypingTelegram,
unpinMessageTelegram,
} from "../../../extensions/telegram/src/send.js";
import { resolveTelegramToken } from "../../../extensions/telegram/src/token.js";
import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js";
import { handleSlackAction } from "../../agents/tools/slack-actions.js";
@@ -113,6 +134,8 @@ import {
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import { buildAgentSessionKey, resolveAgentRoute } from "../../routing/resolve-route.js";
import { createDiscordTypingLease } from "./runtime-discord-typing.js";
import { createTelegramTypingLease } from "./runtime-telegram-typing.js";
import { createRuntimeWhatsApp } from "./runtime-whatsapp.js";
import type { PluginRuntime } from "./types.js";
@@ -207,9 +230,33 @@ export function createRuntimeChannel(): PluginRuntime["channel"] {
probeDiscord,
resolveChannelAllowlist: resolveDiscordChannelAllowlist,
resolveUserAllowlist: resolveDiscordUserAllowlist,
sendComponentMessage: sendDiscordComponentMessage,
sendMessageDiscord,
sendPollDiscord,
monitorDiscordProvider,
typing: {
pulse: sendTypingDiscord,
start: async ({ channelId, accountId, cfg, intervalMs }) =>
await createDiscordTypingLease({
channelId,
accountId,
cfg,
intervalMs,
pulse: async ({ channelId, accountId, cfg }) =>
void (await sendTypingDiscord(channelId, {
accountId,
cfg,
})),
}),
},
conversationActions: {
editMessage: editMessageDiscord,
deleteMessage: deleteMessageDiscord,
pinMessage: pinMessageDiscord,
unpinMessage: unpinMessageDiscord,
createThread: createThreadDiscord,
editChannel: editChannelDiscord,
},
},
slack: {
listDirectoryGroupsLive: listSlackDirectoryGroupsLive,
@@ -230,6 +277,33 @@ export function createRuntimeChannel(): PluginRuntime["channel"] {
sendPollTelegram,
monitorTelegramProvider,
messageActions: telegramMessageActions,
typing: {
pulse: sendTypingTelegram,
start: async ({ to, accountId, cfg, intervalMs, messageThreadId }) =>
await createTelegramTypingLease({
to,
accountId,
cfg,
intervalMs,
messageThreadId,
pulse: async ({ to, accountId, cfg, messageThreadId }) =>
await sendTypingTelegram(to, {
accountId,
cfg,
messageThreadId,
}),
}),
},
conversationActions: {
editMessage: editMessageTelegram,
editReplyMarkup: editMessageReplyMarkupTelegram,
clearReplyMarkup: async (chatIdInput, messageIdInput, opts = {}) =>
await editMessageReplyMarkupTelegram(chatIdInput, messageIdInput, [], opts),
deleteMessage: deleteMessageTelegram,
renameTopic: renameForumTopicTelegram,
pinMessage: pinMessageTelegram,
unpinMessage: unpinMessageTelegram,
},
},
signal: {
probeSignal,

View File

@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDiscordTypingLease } from "./runtime-discord-typing.js";
describe("createDiscordTypingLease", () => {
afterEach(() => {
vi.useRealTimers();
});
it("pulses immediately and keeps leases independent", async () => {
vi.useFakeTimers();
const pulse = vi.fn(async () => undefined);
const leaseA = await createDiscordTypingLease({
channelId: "123",
intervalMs: 2_000,
pulse,
});
const leaseB = await createDiscordTypingLease({
channelId: "123",
intervalMs: 2_000,
pulse,
});
expect(pulse).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(2_000);
expect(pulse).toHaveBeenCalledTimes(4);
leaseA.stop();
await vi.advanceTimersByTimeAsync(2_000);
expect(pulse).toHaveBeenCalledTimes(5);
await leaseB.refresh();
expect(pulse).toHaveBeenCalledTimes(6);
leaseB.stop();
});
it("swallows background pulse failures", async () => {
vi.useFakeTimers();
const pulse = vi
.fn<(params: { channelId: string; accountId?: string; cfg?: unknown }) => Promise<void>>()
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error("boom"));
const lease = await createDiscordTypingLease({
channelId: "123",
intervalMs: 2_000,
pulse,
});
await expect(vi.advanceTimersByTimeAsync(2_000)).resolves.toBe(vi);
expect(pulse).toHaveBeenCalledTimes(2);
lease.stop();
});
});

View File

@@ -0,0 +1,62 @@
import { logWarn } from "../../logger.js";
export type CreateDiscordTypingLeaseParams = {
channelId: string;
accountId?: string;
cfg?: ReturnType<typeof import("../../config/config.js").loadConfig>;
intervalMs?: number;
pulse: (params: {
channelId: string;
accountId?: string;
cfg?: ReturnType<typeof import("../../config/config.js").loadConfig>;
}) => Promise<void>;
};
const DEFAULT_DISCORD_TYPING_INTERVAL_MS = 8_000;
export async function createDiscordTypingLease(params: CreateDiscordTypingLeaseParams): Promise<{
refresh: () => Promise<void>;
stop: () => void;
}> {
const intervalMs =
typeof params.intervalMs === "number" && Number.isFinite(params.intervalMs)
? Math.max(1_000, Math.floor(params.intervalMs))
: DEFAULT_DISCORD_TYPING_INTERVAL_MS;
let stopped = false;
let timer: ReturnType<typeof setInterval> | null = null;
const pulse = async () => {
if (stopped) {
return;
}
await params.pulse({
channelId: params.channelId,
accountId: params.accountId,
cfg: params.cfg,
});
};
await pulse();
timer = setInterval(() => {
// Background lease refreshes must never escape as unhandled rejections.
void pulse().catch((err) => {
logWarn(`plugins: discord typing pulse failed: ${String(err)}`);
});
}, intervalMs);
timer.unref?.();
return {
refresh: async () => {
await pulse();
},
stop: () => {
stopped = true;
if (timer) {
clearInterval(timer);
timer = null;
}
},
};
}

View File

@@ -0,0 +1,83 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTelegramTypingLease } from "./runtime-telegram-typing.js";
describe("createTelegramTypingLease", () => {
afterEach(() => {
vi.useRealTimers();
});
it("pulses immediately and keeps leases independent", async () => {
vi.useFakeTimers();
const pulse = vi.fn(async () => undefined);
const leaseA = await createTelegramTypingLease({
to: "telegram:123",
intervalMs: 2_000,
pulse,
});
const leaseB = await createTelegramTypingLease({
to: "telegram:123",
intervalMs: 2_000,
pulse,
});
expect(pulse).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(2_000);
expect(pulse).toHaveBeenCalledTimes(4);
leaseA.stop();
await vi.advanceTimersByTimeAsync(2_000);
expect(pulse).toHaveBeenCalledTimes(5);
await leaseB.refresh();
expect(pulse).toHaveBeenCalledTimes(6);
leaseB.stop();
});
it("swallows background pulse failures", async () => {
vi.useFakeTimers();
const pulse = vi
.fn<
(params: {
to: string;
accountId?: string;
cfg?: unknown;
messageThreadId?: number;
}) => Promise<unknown>
>()
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error("boom"));
const lease = await createTelegramTypingLease({
to: "telegram:123",
intervalMs: 2_000,
pulse,
});
await expect(vi.advanceTimersByTimeAsync(2_000)).resolves.toBe(vi);
expect(pulse).toHaveBeenCalledTimes(2);
lease.stop();
});
it("falls back to the default interval for non-finite values", async () => {
vi.useFakeTimers();
const pulse = vi.fn(async () => undefined);
const lease = await createTelegramTypingLease({
to: "telegram:123",
intervalMs: Number.NaN,
pulse,
});
expect(pulse).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(3_999);
expect(pulse).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(pulse).toHaveBeenCalledTimes(2);
lease.stop();
});
});

View File

@@ -0,0 +1,60 @@
import type { OpenClawConfig } from "../../config/config.js";
import { logWarn } from "../../logger.js";
export type CreateTelegramTypingLeaseParams = {
to: string;
accountId?: string;
cfg?: OpenClawConfig;
intervalMs?: number;
messageThreadId?: number;
pulse: (params: {
to: string;
accountId?: string;
cfg?: OpenClawConfig;
messageThreadId?: number;
}) => Promise<unknown>;
};
export async function createTelegramTypingLease(params: CreateTelegramTypingLeaseParams): Promise<{
refresh: () => Promise<void>;
stop: () => void;
}> {
const intervalMs =
typeof params.intervalMs === "number" && Number.isFinite(params.intervalMs)
? Math.max(1_000, Math.floor(params.intervalMs))
: 4_000;
let stopped = false;
const refresh = async () => {
if (stopped) {
return;
}
await params.pulse({
to: params.to,
accountId: params.accountId,
cfg: params.cfg,
messageThreadId: params.messageThreadId,
});
};
await refresh();
const timer = setInterval(() => {
// Background lease refreshes must never escape as unhandled rejections.
void refresh().catch((err) => {
logWarn(`plugins: telegram typing pulse failed: ${String(err)}`);
});
}, intervalMs);
timer.unref?.();
return {
refresh,
stop: () => {
if (stopped) {
return;
}
stopped = true;
clearInterval(timer);
},
};
}

View File

@@ -94,9 +94,30 @@ export type PluginRuntimeChannel = {
probeDiscord: typeof import("../../../extensions/discord/src/probe.js").probeDiscord;
resolveChannelAllowlist: typeof import("../../../extensions/discord/src/resolve-channels.js").resolveDiscordChannelAllowlist;
resolveUserAllowlist: typeof import("../../../extensions/discord/src/resolve-users.js").resolveDiscordUserAllowlist;
sendComponentMessage: typeof import("../../../extensions/discord/src/send.js").sendDiscordComponentMessage;
sendMessageDiscord: typeof import("../../../extensions/discord/src/send.js").sendMessageDiscord;
sendPollDiscord: typeof import("../../../extensions/discord/src/send.js").sendPollDiscord;
monitorDiscordProvider: typeof import("../../../extensions/discord/src/monitor.js").monitorDiscordProvider;
typing: {
pulse: typeof import("../../../extensions/discord/src/send.js").sendTypingDiscord;
start: (params: {
channelId: string;
accountId?: string;
cfg?: ReturnType<typeof import("../../config/config.js").loadConfig>;
intervalMs?: number;
}) => Promise<{
refresh: () => Promise<void>;
stop: () => void;
}>;
};
conversationActions: {
editMessage: typeof import("../../../extensions/discord/src/send.js").editMessageDiscord;
deleteMessage: typeof import("../../../extensions/discord/src/send.js").deleteMessageDiscord;
pinMessage: typeof import("../../../extensions/discord/src/send.js").pinMessageDiscord;
unpinMessage: typeof import("../../../extensions/discord/src/send.js").unpinMessageDiscord;
createThread: typeof import("../../../extensions/discord/src/send.js").createThreadDiscord;
editChannel: typeof import("../../../extensions/discord/src/send.js").editChannelDiscord;
};
};
slack: {
listDirectoryGroupsLive: typeof import("../../../extensions/slack/src/directory-live.js").listSlackDirectoryGroupsLive;
@@ -117,6 +138,39 @@ export type PluginRuntimeChannel = {
sendPollTelegram: typeof import("../../../extensions/telegram/src/send.js").sendPollTelegram;
monitorTelegramProvider: typeof import("../../../extensions/telegram/src/monitor.js").monitorTelegramProvider;
messageActions: typeof import("../../channels/plugins/actions/telegram.js").telegramMessageActions;
typing: {
pulse: typeof import("../../../extensions/telegram/src/send.js").sendTypingTelegram;
start: (params: {
to: string;
accountId?: string;
cfg?: ReturnType<typeof import("../../config/config.js").loadConfig>;
intervalMs?: number;
messageThreadId?: number;
}) => Promise<{
refresh: () => Promise<void>;
stop: () => void;
}>;
};
conversationActions: {
editMessage: typeof import("../../../extensions/telegram/src/send.js").editMessageTelegram;
editReplyMarkup: typeof import("../../../extensions/telegram/src/send.js").editMessageReplyMarkupTelegram;
clearReplyMarkup: (
chatIdInput: string | number,
messageIdInput: string | number,
opts?: {
token?: string;
accountId?: string;
verbose?: boolean;
api?: Partial<import("grammy").Bot["api"]>;
retry?: import("../../infra/retry.js").RetryConfig;
cfg?: ReturnType<typeof import("../../config/config.js").loadConfig>;
},
) => Promise<{ ok: true; messageId: string; chatId: string }>;
deleteMessage: typeof import("../../../extensions/telegram/src/send.js").deleteMessageTelegram;
renameTopic: typeof import("../../../extensions/telegram/src/send.js").renameForumTopicTelegram;
pinMessage: typeof import("../../../extensions/telegram/src/send.js").pinMessageTelegram;
unpinMessage: typeof import("../../../extensions/telegram/src/send.js").unpinMessageTelegram;
};
};
signal: {
probeSignal: typeof import("../../../extensions/signal/src/probe.js").probeSignal;