mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
fix(discord): correlate activity launches at click time (#109194)
This commit is contained in:
committed by
GitHub
parent
8f1c6df748
commit
615007d8c6
@@ -121,7 +121,7 @@ Add the user's stable Discord ID to `allowFrom` or `dm.allowFrom` on the same Di
|
||||
|
||||
### “Widget unavailable”
|
||||
|
||||
Launch the button from the channel where the agent posted it. If Discord does not carry the button's custom ID into the Activity, OpenClaw opens the most recently posted live widget in that channel. Buttons that preserve their custom ID continue to open their original widget.
|
||||
Launch the button from the channel where the agent posted it. OpenClaw tracks launches server-side when clicked, so a fresh launch record can resolve the exact widget even when Discord omits or mangles the button's custom ID. When neither the custom ID nor a launch record resolves, OpenClaw opens the most recently posted live widget in that channel. Older widgets remain addressable through buttons that preserve their custom ID.
|
||||
|
||||
### “You cannot launch Activities in this channel”
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ async function startServer(
|
||||
now?: () => number;
|
||||
vendorAssetPath?: string;
|
||||
readVendorAsset?: (assetPath: string) => Promise<Buffer>;
|
||||
logError?: (message: string) => void;
|
||||
} = {},
|
||||
): Promise<string> {
|
||||
const route = createDiscordActivityHttpHandler({
|
||||
@@ -482,6 +483,214 @@ describe("Discord Activity widget routes", () => {
|
||||
expect(secondCsp).toContain("connect-src 'none'");
|
||||
});
|
||||
|
||||
it("retires the matching pending launch when its custom ID resolves", async () => {
|
||||
const runtime = createActivityTestRuntime();
|
||||
await createWidget(runtime, { createdAt: 1 });
|
||||
const launchedId = await createWidget(runtime, { createdAt: 2 });
|
||||
await runtime.store.recordPendingLaunch({
|
||||
accountId: "default",
|
||||
channelId: "777",
|
||||
discordUserId: "42",
|
||||
widgetId: launchedId,
|
||||
createdAt: 3,
|
||||
});
|
||||
const session = await runtime.store.createSession({
|
||||
discordUserId: "42",
|
||||
accountId: "default",
|
||||
});
|
||||
const base = await startServer(runtime, { fetchGuard: guardedJsonFetch() });
|
||||
|
||||
const response = await fetch(
|
||||
`${base}/discord/activity/api/widget?custom_id=${encodeURIComponent(buildDiscordActivityCustomId(launchedId))}&instance_id=instance-1`,
|
||||
{ headers: { Authorization: `Bearer ${session}` } },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Lifecycle closed: a later click on a different widget must not be poisoned.
|
||||
await expect(
|
||||
runtime.store.consumePendingLaunch("default", "777", "42"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps a different-widget pending launch when a custom ID resolves", async () => {
|
||||
const runtime = createActivityTestRuntime();
|
||||
const requestedId = await createWidget(runtime, { createdAt: 1 });
|
||||
const pendingId = await createWidget(runtime, { createdAt: 2 });
|
||||
await runtime.store.recordPendingLaunch({
|
||||
accountId: "default",
|
||||
channelId: "777",
|
||||
discordUserId: "42",
|
||||
widgetId: pendingId,
|
||||
createdAt: 3,
|
||||
});
|
||||
const session = await runtime.store.createSession({
|
||||
discordUserId: "42",
|
||||
accountId: "default",
|
||||
});
|
||||
const base = await startServer(runtime, { fetchGuard: guardedJsonFetch() });
|
||||
|
||||
const response = await fetch(
|
||||
`${base}/discord/activity/api/widget?custom_id=${encodeURIComponent(buildDiscordActivityCustomId(requestedId))}&instance_id=instance-1`,
|
||||
{ headers: { Authorization: `Bearer ${session}` } },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({ id: requestedId });
|
||||
await expect(runtime.store.consumePendingLaunch("default", "777", "42")).resolves.toMatchObject(
|
||||
{ widgetId: pendingId },
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["", "ocactivity1_mangled"])(
|
||||
"resolves %j custom ID through the pending launch",
|
||||
async (customId) => {
|
||||
const runtime = createActivityTestRuntime();
|
||||
const pendingId = await createWidget(runtime, { createdAt: 1 });
|
||||
await createWidget(runtime, { createdAt: 2 });
|
||||
await runtime.store.recordPendingLaunch({
|
||||
accountId: "default",
|
||||
channelId: "777",
|
||||
discordUserId: "42",
|
||||
widgetId: pendingId,
|
||||
createdAt: 3,
|
||||
});
|
||||
const session = await runtime.store.createSession({
|
||||
discordUserId: "42",
|
||||
accountId: "default",
|
||||
});
|
||||
const base = await startServer(runtime, { fetchGuard: guardedJsonFetch() });
|
||||
|
||||
const response = await fetch(
|
||||
`${base}/discord/activity/api/widget?custom_id=${encodeURIComponent(customId)}&instance_id=instance-1`,
|
||||
{ headers: { Authorization: `Bearer ${session}` } },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({ id: pendingId });
|
||||
},
|
||||
);
|
||||
|
||||
it("falls through to the newest widget when overlapping launches target different widgets", async () => {
|
||||
const runtime = createActivityTestRuntime();
|
||||
const firstId = await createWidget(runtime, { createdAt: 1 });
|
||||
const newestId = await createWidget(runtime, { createdAt: 2 });
|
||||
const record = (widgetId: string, createdAt: number) =>
|
||||
runtime.store.recordPendingLaunch({
|
||||
accountId: "default",
|
||||
channelId: "777",
|
||||
discordUserId: "42",
|
||||
widgetId,
|
||||
createdAt,
|
||||
});
|
||||
await Promise.all([record(firstId, 3), record(newestId, 4)]);
|
||||
const session = await runtime.store.createSession({
|
||||
discordUserId: "42",
|
||||
accountId: "default",
|
||||
});
|
||||
const base = await startServer(runtime, { fetchGuard: guardedJsonFetch() });
|
||||
|
||||
const response = await fetch(`${base}/discord/activity/api/widget?instance_id=instance-1`, {
|
||||
headers: { Authorization: `Bearer ${session}` },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({ id: newestId });
|
||||
await expect(
|
||||
runtime.store.consumePendingLaunch("default", "777", "42"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps a pending launch when the same widget is clicked twice", async () => {
|
||||
const runtime = createActivityTestRuntime();
|
||||
const widgetId = await createWidget(runtime, { createdAt: 1 });
|
||||
const record = (createdAt: number) =>
|
||||
runtime.store.recordPendingLaunch({
|
||||
accountId: "default",
|
||||
channelId: "777",
|
||||
discordUserId: "42",
|
||||
widgetId,
|
||||
createdAt,
|
||||
});
|
||||
await record(2);
|
||||
await record(3);
|
||||
|
||||
await expect(runtime.store.consumePendingLaunch("default", "777", "42")).resolves.toMatchObject(
|
||||
{ widgetId },
|
||||
);
|
||||
});
|
||||
|
||||
it("consumes a pending launch after one widget resolution", async () => {
|
||||
const runtime = createActivityTestRuntime();
|
||||
const pendingId = await createWidget(runtime, { createdAt: 1 });
|
||||
const newestId = await createWidget(runtime, { createdAt: 2 });
|
||||
await runtime.store.recordPendingLaunch({
|
||||
accountId: "default",
|
||||
channelId: "777",
|
||||
discordUserId: "42",
|
||||
widgetId: pendingId,
|
||||
createdAt: 3,
|
||||
});
|
||||
const session = await runtime.store.createSession({
|
||||
discordUserId: "42",
|
||||
accountId: "default",
|
||||
});
|
||||
const base = await startServer(runtime, { fetchGuard: guardedJsonFetch() });
|
||||
const url = `${base}/discord/activity/api/widget?instance_id=instance-1`;
|
||||
|
||||
const first = await fetch(url, { headers: { Authorization: `Bearer ${session}` } });
|
||||
const second = await fetch(url, { headers: { Authorization: `Bearer ${session}` } });
|
||||
|
||||
expect(first.status).toBe(200);
|
||||
await expect(first.json()).resolves.toMatchObject({ id: pendingId });
|
||||
expect(second.status).toBe(200);
|
||||
await expect(second.json()).resolves.toMatchObject({ id: newestId });
|
||||
});
|
||||
|
||||
it("keeps pending launches isolated by Discord account", async () => {
|
||||
const runtime = createActivityTestRuntime();
|
||||
await runtime.store.recordPendingLaunch({
|
||||
accountId: "account-b",
|
||||
channelId: "777",
|
||||
discordUserId: "42",
|
||||
widgetId: "AAAAAAAAAAAAAAAAAAAAAA",
|
||||
createdAt: 1,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runtime.store.consumePendingLaunch("account-a", "777", "42"),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
runtime.store.consumePendingLaunch("account-b", "777", "42"),
|
||||
).resolves.toMatchObject({ widgetId: "AAAAAAAAAAAAAAAAAAAAAA" });
|
||||
});
|
||||
|
||||
it("keeps the newest-widget fallback when pending launch lookup fails", async () => {
|
||||
const runtime = createActivityTestRuntime();
|
||||
await createWidget(runtime, { createdAt: 1 });
|
||||
const newestId = await createWidget(runtime, { createdAt: 2 });
|
||||
const session = await runtime.store.createSession({
|
||||
discordUserId: "42",
|
||||
accountId: "default",
|
||||
});
|
||||
const consumePendingLaunch = vi
|
||||
.spyOn(runtime.store, "consumePendingLaunch")
|
||||
.mockRejectedValue(new Error("store offline"));
|
||||
const logError = vi.fn();
|
||||
const base = await startServer(runtime, {
|
||||
fetchGuard: guardedJsonFetch(),
|
||||
logError,
|
||||
});
|
||||
const url = `${base}/discord/activity/api/widget?custom_id=missing&instance_id=instance-1`;
|
||||
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
const response = await fetch(url, { headers: { Authorization: `Bearer ${session}` } });
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({ id: newestId });
|
||||
}
|
||||
expect(consumePendingLaunch).toHaveBeenCalledTimes(2);
|
||||
expect(logError).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects a custom ID outside the validated instance channel", async () => {
|
||||
const runtime = createActivityTestRuntime();
|
||||
const widgetId = await createWidget(runtime, { channelId: "888" });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { logError } from "openclaw/plugin-sdk/logging-core";
|
||||
import { resolveRequestClientIp } from "openclaw/plugin-sdk/webhook-ingress";
|
||||
import { parseDiscordActivityCustomId } from "../component-custom-id.js";
|
||||
import { resolveActivityUserAuthorized } from "./allowlist.js";
|
||||
@@ -38,6 +39,7 @@ type DiscordActivityHttpDeps = {
|
||||
fetchGuard?: FetchGuard;
|
||||
now?: () => number;
|
||||
readVendorAsset?: (assetPath: string) => Promise<Buffer>;
|
||||
logError?: (message: string) => void;
|
||||
};
|
||||
|
||||
type DiscordOauthUser = {
|
||||
@@ -144,7 +146,17 @@ export function createDiscordActivityHttpHandler(deps: DiscordActivityHttpDeps):
|
||||
const fetchGuard = deps.fetchGuard ?? fetchWithSsrFGuard;
|
||||
const limiter = new TokenRateLimiter(deps.now ?? Date.now);
|
||||
const readVendorAsset = deps.readVendorAsset ?? ((assetPath: string) => fs.readFile(assetPath));
|
||||
const reportError = deps.logError ?? logError;
|
||||
let vendorAsset: Promise<Buffer> | undefined;
|
||||
let pendingLaunchFailureLogged = false;
|
||||
|
||||
function logPendingLaunchFailure(error: unknown): void {
|
||||
if (pendingLaunchFailureLogged) {
|
||||
return;
|
||||
}
|
||||
pendingLaunchFailureLogged = true;
|
||||
reportError(`discord activity: failed to consume pending launch: ${String(error)}`);
|
||||
}
|
||||
|
||||
async function handleToken(req: IncomingMessage, res: ServerResponse): Promise<true> {
|
||||
const cfg = deps.runtime.currentConfig();
|
||||
@@ -270,18 +282,48 @@ export function createDiscordActivityHttpHandler(deps: DiscordActivityHttpDeps):
|
||||
let resolved: {
|
||||
id: string;
|
||||
widget: NonNullable<Awaited<ReturnType<typeof deps.runtime.store.lookupWidget>>>;
|
||||
} | null;
|
||||
} | null = null;
|
||||
// Prefer an explicit ID, then the click-time launch record, then the newest posted widget.
|
||||
const requestedWidgetId = widgetIdFromCustomId(customId);
|
||||
if (requestedWidgetId) {
|
||||
const widget = await deps.runtime.store.lookupWidget(requestedWidgetId);
|
||||
// A parseable ID is an explicit widget selection. Missing or foreign widgets fail closed
|
||||
// instead of silently opening unrelated pending or newest-widget state.
|
||||
if (widget?.accountId !== session.accountId || widget.channelId !== channelId) {
|
||||
return respondJson(res, 404, { error: "widget not found" });
|
||||
}
|
||||
resolved = { id: requestedWidgetId, widget };
|
||||
// Awaited like every sibling store call on this path (sessions, widgets): the local
|
||||
// KV either answers or the process is wedged; per-call budgets here would be asymmetric.
|
||||
try {
|
||||
await deps.runtime.store.retirePendingLaunch(
|
||||
session.accountId,
|
||||
channelId,
|
||||
session.discordUserId,
|
||||
requestedWidgetId,
|
||||
);
|
||||
} catch (error) {
|
||||
logPendingLaunchFailure(error);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const pendingLaunch = await deps.runtime.store.consumePendingLaunch(
|
||||
session.accountId,
|
||||
channelId,
|
||||
session.discordUserId,
|
||||
);
|
||||
if (pendingLaunch) {
|
||||
const widget = await deps.runtime.store.lookupWidget(pendingLaunch.widgetId);
|
||||
if (widget?.accountId === session.accountId && widget.channelId === channelId) {
|
||||
resolved = { id: pendingLaunch.widgetId, widget };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logPendingLaunchFailure(error);
|
||||
}
|
||||
// Some Discord clients omit the launch custom ID. Prefer the most recently posted channel
|
||||
// widget while keeping older widgets addressable through buttons that preserve custom IDs.
|
||||
resolved = await deps.runtime.store.latestPostedWidgetForChannel(
|
||||
resolved ??= await deps.runtime.store.latestPostedWidgetForChannel(
|
||||
session.accountId,
|
||||
channelId,
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ButtonInteraction } from "../internal/discord.js";
|
||||
import { createInteraction } from "../internal/interactions.js";
|
||||
import {
|
||||
attachRestMock,
|
||||
createDeferred,
|
||||
createInternalComponentInteractionPayload,
|
||||
createInternalTestClient,
|
||||
} from "../internal/test-builders.test-support.js";
|
||||
@@ -52,7 +53,10 @@ describe("Discord Activity interaction", () => {
|
||||
createInternalComponentInteractionPayload({
|
||||
id: "interaction-1",
|
||||
token: "itoken",
|
||||
data: { component_type: ComponentType.Button, custom_id: "ocactivity:v=1;wid=x" },
|
||||
data: {
|
||||
component_type: ComponentType.Button,
|
||||
custom_id: "ocactivity1_AAAAAAAAAAAAAAAAAAAAAA",
|
||||
},
|
||||
}),
|
||||
) as ButtonInteraction;
|
||||
|
||||
@@ -73,7 +77,11 @@ describe("Discord Activity interaction", () => {
|
||||
reply: reply as never,
|
||||
});
|
||||
const launchActivity = vi.fn(async () => undefined);
|
||||
const interaction = { launchActivity } as unknown as ButtonInteraction;
|
||||
const interaction = {
|
||||
launchActivity,
|
||||
rawData: { channel_id: "777" },
|
||||
userId: "42",
|
||||
} as unknown as ButtonInteraction;
|
||||
const rendered = buildDiscordPresentationComponents({
|
||||
blocks: [
|
||||
{
|
||||
@@ -97,6 +105,99 @@ describe("Discord Activity interaction", () => {
|
||||
expect(authorize).toHaveBeenCalledOnce();
|
||||
expect(launchActivity).toHaveBeenCalledOnce();
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
await expect(runtime.store.consumePendingLaunch("default", "777", "42")).resolves.toMatchObject(
|
||||
{ widgetId: "AAAAAAAAAAAAAAAAAAAAAA" },
|
||||
);
|
||||
});
|
||||
|
||||
it("records the pending launch before acknowledging the interaction", async () => {
|
||||
const runtime = createActivityTestRuntime();
|
||||
setDiscordActivitiesRuntime(runtime);
|
||||
const recordPendingLaunch = vi.spyOn(runtime.store, "recordPendingLaunch");
|
||||
const button = createDiscordActivityButton(componentContext(), "123456789012345678", {
|
||||
authorize: vi.fn(async () => ({ commandAuthorized: true })) as never,
|
||||
reply: vi.fn(async () => undefined) as never,
|
||||
});
|
||||
if (!button) {
|
||||
throw new Error("expected activity button");
|
||||
}
|
||||
const launchActivity = vi.fn(async () => undefined);
|
||||
const interaction = {
|
||||
launchActivity,
|
||||
rawData: { channel_id: "777" },
|
||||
userId: "42",
|
||||
} as unknown as ButtonInteraction;
|
||||
await button.run(interaction, { widgetId: "AAAAAAAAAAAAAAAAAAAAAA" });
|
||||
|
||||
expect(recordPendingLaunch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
channelId: "777",
|
||||
discordUserId: "42",
|
||||
}),
|
||||
);
|
||||
expect(launchActivity).toHaveBeenCalledOnce();
|
||||
// The bounded await establishes visibility before the Activity can query api/widget.
|
||||
const writeOrder = recordPendingLaunch.mock.invocationCallOrder[0] ?? Number.NaN;
|
||||
const launchOrder = launchActivity.mock.invocationCallOrder[0] ?? Number.NaN;
|
||||
expect(writeOrder).toBeLessThan(launchOrder);
|
||||
});
|
||||
|
||||
it("launches after the write budget when the store stalls and logs once", async () => {
|
||||
const runtime = createActivityTestRuntime();
|
||||
setDiscordActivitiesRuntime(runtime);
|
||||
const pendingWrite = createDeferred<void>();
|
||||
vi.spyOn(runtime.store, "recordPendingLaunch").mockReturnValue(pendingWrite.promise);
|
||||
const logError = vi.fn();
|
||||
const button = createDiscordActivityButton(componentContext(), "123456789012345678", {
|
||||
authorize: vi.fn(async () => ({ commandAuthorized: true })) as never,
|
||||
reply: vi.fn(async () => undefined) as never,
|
||||
logError,
|
||||
});
|
||||
if (!button) {
|
||||
throw new Error("expected activity button");
|
||||
}
|
||||
const launchActivity = vi.fn(async () => undefined);
|
||||
const interaction = {
|
||||
launchActivity,
|
||||
rawData: { channel_id: "777" },
|
||||
userId: "42",
|
||||
} as unknown as ButtonInteraction;
|
||||
try {
|
||||
await button.run(interaction, { widgetId: "AAAAAAAAAAAAAAAAAAAAAA" });
|
||||
expect(launchActivity).toHaveBeenCalledOnce();
|
||||
expect(logError).toHaveBeenCalledTimes(1);
|
||||
expect(String(logError.mock.calls[0]?.[0])).toContain("exceeded");
|
||||
} finally {
|
||||
pendingWrite.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
it("still launches when recording the pending launch fails and logs once", async () => {
|
||||
const runtime = createActivityTestRuntime();
|
||||
setDiscordActivitiesRuntime(runtime);
|
||||
const recordPendingLaunch = vi
|
||||
.spyOn(runtime.store, "recordPendingLaunch")
|
||||
.mockRejectedValue(new Error("store offline"));
|
||||
const logError = vi.fn();
|
||||
const button = createDiscordActivityButton(componentContext(), "123456789012345678", {
|
||||
authorize: vi.fn(async () => ({ commandAuthorized: true })) as never,
|
||||
reply: vi.fn(async () => undefined) as never,
|
||||
logError,
|
||||
});
|
||||
const launchActivity = vi.fn(async () => undefined);
|
||||
const interaction = {
|
||||
launchActivity,
|
||||
rawData: { channel_id: "777" },
|
||||
userId: "42",
|
||||
} as unknown as ButtonInteraction;
|
||||
|
||||
await button?.run(interaction, { widgetId: "AAAAAAAAAAAAAAAAAAAAAA" });
|
||||
await button?.run(interaction, { widgetId: "AAAAAAAAAAAAAAAAAAAAAA" });
|
||||
|
||||
expect(recordPendingLaunch).toHaveBeenCalledTimes(2);
|
||||
expect(launchActivity).toHaveBeenCalledTimes(2);
|
||||
await vi.waitFor(() => expect(logError).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it("replies ephemerally and does not launch when unauthorized", async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { logError } from "openclaw/plugin-sdk/logging-core";
|
||||
import {
|
||||
buildDiscordActivityCustomId,
|
||||
parseDiscordActivityCustomIdForInteraction,
|
||||
@@ -11,6 +12,8 @@ import { getDiscordActivitiesRuntime } from "./runtime.js";
|
||||
|
||||
const REGISTRATION_WIDGET_ID = "AAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
const PENDING_LAUNCH_WRITE_BUDGET_MS = 250;
|
||||
|
||||
class DiscordActivityButton extends Button {
|
||||
label = "Open widget";
|
||||
customId = buildDiscordActivityCustomId(REGISTRATION_WIDGET_ID);
|
||||
@@ -21,11 +24,22 @@ class DiscordActivityButton extends Button {
|
||||
private readonly deps: {
|
||||
authorize: typeof resolveAuthorizedComponentInteraction;
|
||||
reply: typeof replySilently;
|
||||
logError: (message: string) => void;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
private pendingLaunchFailureLogged = false;
|
||||
|
||||
private logPendingLaunchFailure(error: unknown): void {
|
||||
if (this.pendingLaunchFailureLogged) {
|
||||
return;
|
||||
}
|
||||
this.pendingLaunchFailureLogged = true;
|
||||
this.deps.logError(`discord activity: failed to record pending launch: ${String(error)}`);
|
||||
}
|
||||
|
||||
override async run(interaction: ButtonInteraction, data: ComponentData): Promise<void> {
|
||||
if (typeof data.widgetId !== "string") {
|
||||
await this.deps.reply(interaction, {
|
||||
@@ -49,6 +63,39 @@ class DiscordActivityButton extends Button {
|
||||
await this.deps.reply(interaction, { content: "not allowed", ephemeral: true });
|
||||
return;
|
||||
}
|
||||
const runtime = getDiscordActivitiesRuntime();
|
||||
const channelId = interaction.rawData.channel_id;
|
||||
const discordUserId = interaction.userId;
|
||||
if (!runtime || !channelId || !discordUserId) {
|
||||
this.logPendingLaunchFailure(new Error("missing activity runtime or interaction identity"));
|
||||
} else {
|
||||
// Await the write within a small budget so the record is visible before the Activity
|
||||
// can query api/widget, while never risking Discord's 3-second interaction ack: a
|
||||
// healthy store commits in single-digit milliseconds; on timeout the write continues
|
||||
// in the background and the mangled-ID multi-widget case degrades to fail-closed.
|
||||
const write = runtime.store
|
||||
.recordPendingLaunch({
|
||||
accountId: this.ctx.accountId,
|
||||
channelId,
|
||||
discordUserId,
|
||||
widgetId: data.widgetId,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
.then(() => "written" as const)
|
||||
.catch((error: unknown) => {
|
||||
this.logPendingLaunchFailure(error);
|
||||
return "failed" as const;
|
||||
});
|
||||
const timeout = new Promise<"timeout">((resolve) => {
|
||||
const timer = setTimeout(() => resolve("timeout"), PENDING_LAUNCH_WRITE_BUDGET_MS);
|
||||
timer.unref?.();
|
||||
});
|
||||
if ((await Promise.race([write, timeout])) === "timeout") {
|
||||
this.logPendingLaunchFailure(
|
||||
new Error(`pending launch write exceeded ${PENDING_LAUNCH_WRITE_BUDGET_MS}ms`),
|
||||
);
|
||||
}
|
||||
}
|
||||
await interaction.launchActivity();
|
||||
}
|
||||
}
|
||||
@@ -59,6 +106,7 @@ export function createDiscordActivityButton(
|
||||
deps: {
|
||||
authorize?: typeof resolveAuthorizedComponentInteraction;
|
||||
reply?: typeof replySilently;
|
||||
logError?: (message: string) => void;
|
||||
} = {},
|
||||
): DiscordActivityButton | null {
|
||||
const runtime = getDiscordActivitiesRuntime();
|
||||
@@ -74,5 +122,6 @@ export function createDiscordActivityButton(
|
||||
return new DiscordActivityButton(ctx, {
|
||||
authorize: deps.authorize ?? resolveAuthorizedComponentInteraction,
|
||||
reply: deps.reply ?? replySilently,
|
||||
logError: deps.logError ?? logError,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ const DISCORD_EPOCH_MS = 1_420_070_400_000;
|
||||
const WIDGET_TTL_MS = 7 * DAY_MS;
|
||||
const SESSION_TTL_MS = 15 * 60 * 1000;
|
||||
const DOC_TOKEN_TTL_MS = 60 * 1000;
|
||||
const PENDING_LAUNCH_TTL_MS = 2 * 60 * 1000;
|
||||
|
||||
type DiscordActivityWidget = {
|
||||
html: string;
|
||||
@@ -26,6 +27,10 @@ type DiscordActivityDocToken = {
|
||||
accountId: string;
|
||||
};
|
||||
|
||||
type DiscordActivityPendingLaunch =
|
||||
| { state: "single"; widgetId: string; createdAt: number }
|
||||
| { state: "ambiguous"; createdAt: number };
|
||||
|
||||
type AtomicPluginStateKeyedStore<T> = PluginStateKeyedStore<T> & {
|
||||
update: NonNullable<PluginStateKeyedStore<T>["update"]>;
|
||||
};
|
||||
@@ -34,6 +39,7 @@ type DiscordActivityStores = {
|
||||
widgets: AtomicPluginStateKeyedStore<DiscordActivityWidget>;
|
||||
sessions: PluginStateKeyedStore<DiscordActivitySession>;
|
||||
docTokens: PluginStateKeyedStore<DiscordActivityDocToken>;
|
||||
launches: AtomicPluginStateKeyedStore<DiscordActivityPendingLaunch>;
|
||||
};
|
||||
|
||||
type OpenKeyedStore = <T>(options: {
|
||||
@@ -72,9 +78,21 @@ export function openDiscordActivityStores(openKeyedStore: OpenKeyedStore): Disco
|
||||
overflowPolicy: "evict-oldest",
|
||||
defaultTtlMs: DOC_TOKEN_TTL_MS,
|
||||
}),
|
||||
launches: requireAtomicUpdate(
|
||||
openKeyedStore<DiscordActivityPendingLaunch>({
|
||||
namespace: "activities-launches",
|
||||
maxEntries: 256,
|
||||
overflowPolicy: "evict-oldest",
|
||||
defaultTtlMs: PENDING_LAUNCH_TTL_MS,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function pendingLaunchKey(accountId: string, channelId: string, discordUserId: string): string {
|
||||
return `${accountId}:${channelId}:${discordUserId}`;
|
||||
}
|
||||
|
||||
export class DiscordActivityStore {
|
||||
private lastWidgetCreatedAt = 0;
|
||||
|
||||
@@ -155,4 +173,50 @@ export class DiscordActivityStore {
|
||||
async consumeDocToken(token: string): Promise<DiscordActivityDocToken | undefined> {
|
||||
return await this.stores.docTokens.consume(token);
|
||||
}
|
||||
|
||||
async recordPendingLaunch(params: {
|
||||
accountId: string;
|
||||
channelId: string;
|
||||
discordUserId: string;
|
||||
widgetId: string;
|
||||
createdAt: number;
|
||||
}): Promise<void> {
|
||||
const key = pendingLaunchKey(params.accountId, params.channelId, params.discordUserId);
|
||||
// Overlapping clicks on different widgets are ambiguous: which Activity queries first is
|
||||
// unordered, so a single slot could hand widget B's record to widget A's shell. Poison the
|
||||
// slot instead; consume then returns nothing and resolution falls through to the newest post.
|
||||
await this.stores.launches.update(key, (existing) => {
|
||||
const overlapsDifferentWidget =
|
||||
existing && (existing.state === "ambiguous" || existing.widgetId !== params.widgetId);
|
||||
return overlapsDifferentWidget
|
||||
? { state: "ambiguous", createdAt: params.createdAt }
|
||||
: { state: "single", widgetId: params.widgetId, createdAt: params.createdAt };
|
||||
});
|
||||
}
|
||||
|
||||
async retirePendingLaunch(
|
||||
accountId: string,
|
||||
channelId: string,
|
||||
discordUserId: string,
|
||||
widgetId: string,
|
||||
): Promise<void> {
|
||||
// Close the launch lifecycle when custom_id resolution succeeds so a completed
|
||||
// launch cannot poison the next click on a different widget for the whole TTL.
|
||||
// Different-widget and ambiguous records stay: their Activities may still query.
|
||||
const key = pendingLaunchKey(accountId, channelId, discordUserId);
|
||||
await this.stores.launches.update(key, (existing) =>
|
||||
existing?.state === "single" && existing.widgetId === widgetId ? undefined : existing,
|
||||
);
|
||||
}
|
||||
|
||||
async consumePendingLaunch(
|
||||
accountId: string,
|
||||
channelId: string,
|
||||
discordUserId: string,
|
||||
): Promise<Extract<DiscordActivityPendingLaunch, { state: "single" }> | undefined> {
|
||||
const launch = await this.stores.launches.consume(
|
||||
pendingLaunchKey(accountId, channelId, discordUserId),
|
||||
);
|
||||
return launch?.state === "single" ? launch : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ type DiscordActivitySession = NonNullable<
|
||||
type DiscordActivityDocToken = NonNullable<
|
||||
Awaited<ReturnType<DiscordActivityStore["consumeDocToken"]>>
|
||||
>;
|
||||
type DiscordActivityPendingLaunch =
|
||||
| NonNullable<Awaited<ReturnType<DiscordActivityStore["consumePendingLaunch"]>>>
|
||||
| { state: "ambiguous"; createdAt: number };
|
||||
type DiscordActivityStores = ConstructorParameters<typeof DiscordActivityStore>[0];
|
||||
|
||||
export function createMemoryKeyedStore<T>(): PluginStateKeyedStore<T> & {
|
||||
@@ -63,6 +66,7 @@ export function createMemoryActivityStore(): DiscordActivityStore {
|
||||
widgets: createMemoryKeyedStore<DiscordActivityWidget>(),
|
||||
sessions: createMemoryKeyedStore<DiscordActivitySession>(),
|
||||
docTokens: createMemoryKeyedStore<DiscordActivityDocToken>(),
|
||||
launches: createMemoryKeyedStore<DiscordActivityPendingLaunch>(),
|
||||
};
|
||||
return new DiscordActivityStore(stores);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDiscordActivityCustomId,
|
||||
parseDiscordActivityCustomId,
|
||||
} from "./component-custom-id.js";
|
||||
|
||||
describe("Discord Activity custom IDs", () => {
|
||||
const widgetId = "AbCdEfGhIjKlMnOpQrSt_-";
|
||||
|
||||
it("round-trips the URL-safe format", () => {
|
||||
const customId = buildDiscordActivityCustomId(widgetId);
|
||||
|
||||
expect(customId).toBe(`ocactivity1_${widgetId}`);
|
||||
expect(customId).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
expect(parseDiscordActivityCustomId(customId)).toEqual({ widgetId });
|
||||
});
|
||||
|
||||
it("keeps parsing the legacy format", () => {
|
||||
expect(parseDiscordActivityCustomId(`ocactivity:v=1;wid=${widgetId}`)).toEqual({ widgetId });
|
||||
});
|
||||
|
||||
it("rejects malformed and unrelated IDs", () => {
|
||||
for (const customId of [
|
||||
"",
|
||||
"other1_AbCdEfGhIjKlMnOpQrSt_-",
|
||||
"ocactivity1_short",
|
||||
`ocactivity2_${widgetId}`,
|
||||
"ocactivity:v=1;wid=short",
|
||||
]) {
|
||||
expect(parseDiscordActivityCustomId(customId)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,16 +10,22 @@ export const DISCORD_COMPONENT_CUSTOM_ID_KEY = "occomp";
|
||||
export const DISCORD_MODAL_CUSTOM_ID_KEY = "ocmodal";
|
||||
const DISCORD_ACTIVITY_CUSTOM_ID_KEY = "ocactivity";
|
||||
const ENCODED_CUSTOM_ID_VERSION = "1";
|
||||
const DISCORD_ACTIVITY_CUSTOM_ID_PREFIX = `${DISCORD_ACTIVITY_CUSTOM_ID_KEY}${ENCODED_CUSTOM_ID_VERSION}_`;
|
||||
|
||||
export function isValidDiscordActivityWidgetId(widgetId: string): boolean {
|
||||
return /^[A-Za-z0-9_-]{22}$/.test(widgetId);
|
||||
}
|
||||
|
||||
export function buildDiscordActivityCustomId(widgetId: string): string {
|
||||
return `${DISCORD_ACTIVITY_CUSTOM_ID_KEY}:v=${ENCODED_CUSTOM_ID_VERSION};wid=${widgetId}`;
|
||||
return `${DISCORD_ACTIVITY_CUSTOM_ID_PREFIX}${widgetId}`;
|
||||
}
|
||||
|
||||
export function parseDiscordActivityCustomId(id: string): { widgetId: string } | null {
|
||||
if (id.startsWith(DISCORD_ACTIVITY_CUSTOM_ID_PREFIX)) {
|
||||
const widgetId = id.slice(DISCORD_ACTIVITY_CUSTOM_ID_PREFIX.length);
|
||||
return isValidDiscordActivityWidgetId(widgetId) ? { widgetId } : null;
|
||||
}
|
||||
// Discord messages keep buttons indefinitely, so the shipped delimiter format stays parseable.
|
||||
const parsed = parseCustomId(id);
|
||||
if (
|
||||
parsed.key !== DISCORD_ACTIVITY_CUSTOM_ID_KEY ||
|
||||
|
||||
Reference in New Issue
Block a user