feat: thread-bound subagents on Discord (#21805)
* docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
This commit is contained in:
@@ -2,6 +2,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import { discordPlugin } from "./src/channel.js";
|
||||
import { setDiscordRuntime } from "./src/runtime.js";
|
||||
import { registerDiscordSubagentHooks } from "./src/subagent-hooks.js";
|
||||
|
||||
const plugin = {
|
||||
id: "discord",
|
||||
@@ -11,6 +12,7 @@ const plugin = {
|
||||
register(api: OpenClawPluginApi) {
|
||||
setDiscordRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: discordPlugin });
|
||||
registerDiscordSubagentHooks(api);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
430
extensions/discord/src/subagent-hooks.test.ts
Normal file
430
extensions/discord/src/subagent-hooks.test.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerDiscordSubagentHooks } from "./subagent-hooks.js";
|
||||
|
||||
type ThreadBindingRecord = {
|
||||
accountId: string;
|
||||
threadId: string;
|
||||
};
|
||||
|
||||
type MockResolvedDiscordAccount = {
|
||||
accountId: string;
|
||||
config: {
|
||||
threadBindings?: {
|
||||
enabled?: boolean;
|
||||
spawnSubagentSessions?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const hookMocks = vi.hoisted(() => ({
|
||||
resolveDiscordAccount: vi.fn(
|
||||
(params?: { accountId?: string }): MockResolvedDiscordAccount => ({
|
||||
accountId: params?.accountId?.trim() || "default",
|
||||
config: {
|
||||
threadBindings: {
|
||||
spawnSubagentSessions: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
autoBindSpawnedDiscordSubagent: vi.fn(
|
||||
async (): Promise<{ threadId: string } | null> => ({ threadId: "thread-1" }),
|
||||
),
|
||||
listThreadBindingsBySessionKey: vi.fn((_params?: unknown): ThreadBindingRecord[] => []),
|
||||
unbindThreadBindingsBySessionKey: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk", () => ({
|
||||
resolveDiscordAccount: hookMocks.resolveDiscordAccount,
|
||||
autoBindSpawnedDiscordSubagent: hookMocks.autoBindSpawnedDiscordSubagent,
|
||||
listThreadBindingsBySessionKey: hookMocks.listThreadBindingsBySessionKey,
|
||||
unbindThreadBindingsBySessionKey: hookMocks.unbindThreadBindingsBySessionKey,
|
||||
}));
|
||||
|
||||
function registerHandlersForTest(
|
||||
config: Record<string, unknown> = {
|
||||
channels: {
|
||||
discord: {
|
||||
threadBindings: {
|
||||
spawnSubagentSessions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
) {
|
||||
const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
|
||||
const api = {
|
||||
config,
|
||||
on: (hookName: string, handler: (event: unknown, ctx: unknown) => unknown) => {
|
||||
handlers.set(hookName, handler);
|
||||
},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
registerDiscordSubagentHooks(api);
|
||||
return handlers;
|
||||
}
|
||||
|
||||
describe("discord subagent hook handlers", () => {
|
||||
beforeEach(() => {
|
||||
hookMocks.resolveDiscordAccount.mockClear();
|
||||
hookMocks.resolveDiscordAccount.mockImplementation((params?: { accountId?: string }) => ({
|
||||
accountId: params?.accountId?.trim() || "default",
|
||||
config: {
|
||||
threadBindings: {
|
||||
spawnSubagentSessions: true,
|
||||
},
|
||||
},
|
||||
}));
|
||||
hookMocks.autoBindSpawnedDiscordSubagent.mockClear();
|
||||
hookMocks.listThreadBindingsBySessionKey.mockClear();
|
||||
hookMocks.unbindThreadBindingsBySessionKey.mockClear();
|
||||
});
|
||||
|
||||
it("registers subagent hooks", () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
expect(handlers.has("subagent_spawning")).toBe(true);
|
||||
expect(handlers.has("subagent_delivery_target")).toBe(true);
|
||||
expect(handlers.has("subagent_spawned")).toBe(false);
|
||||
expect(handlers.has("subagent_ended")).toBe(true);
|
||||
});
|
||||
|
||||
it("binds thread routing on subagent_spawning", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const handler = handlers.get("subagent_spawning");
|
||||
if (!handler) {
|
||||
throw new Error("expected subagent_spawning hook handler");
|
||||
}
|
||||
|
||||
const result = await handler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
label: "banana",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
to: "channel:123",
|
||||
threadId: "456",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledTimes(1);
|
||||
expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledWith({
|
||||
accountId: "work",
|
||||
channel: "discord",
|
||||
to: "channel:123",
|
||||
threadId: "456",
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
label: "banana",
|
||||
boundBy: "system",
|
||||
});
|
||||
expect(result).toMatchObject({ status: "ok", threadBindingReady: true });
|
||||
});
|
||||
|
||||
it("returns error when thread-bound subagent spawn is disabled", async () => {
|
||||
const handlers = registerHandlersForTest({
|
||||
channels: {
|
||||
discord: {
|
||||
threadBindings: {
|
||||
spawnSubagentSessions: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const handler = handlers.get("subagent_spawning");
|
||||
if (!handler) {
|
||||
throw new Error("expected subagent_spawning hook handler");
|
||||
}
|
||||
|
||||
const result = await handler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
requester: {
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
to: "channel:123",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ status: "error" });
|
||||
const errorText = (result as { error?: string }).error ?? "";
|
||||
expect(errorText).toContain("spawnSubagentSessions=true");
|
||||
});
|
||||
|
||||
it("returns error when global thread bindings are disabled", async () => {
|
||||
const handlers = registerHandlersForTest({
|
||||
session: {
|
||||
threadBindings: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
discord: {
|
||||
threadBindings: {
|
||||
spawnSubagentSessions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const handler = handlers.get("subagent_spawning");
|
||||
if (!handler) {
|
||||
throw new Error("expected subagent_spawning hook handler");
|
||||
}
|
||||
|
||||
const result = await handler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
requester: {
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
to: "channel:123",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ status: "error" });
|
||||
const errorText = (result as { error?: string }).error ?? "";
|
||||
expect(errorText).toContain("threadBindings.enabled=true");
|
||||
});
|
||||
|
||||
it("allows account-level threadBindings.enabled to override global disable", async () => {
|
||||
const handlers = registerHandlersForTest({
|
||||
session: {
|
||||
threadBindings: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
discord: {
|
||||
accounts: {
|
||||
work: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
spawnSubagentSessions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const handler = handlers.get("subagent_spawning");
|
||||
if (!handler) {
|
||||
throw new Error("expected subagent_spawning hook handler");
|
||||
}
|
||||
|
||||
const result = await handler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
requester: {
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
to: "channel:123",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledTimes(1);
|
||||
expect(result).toMatchObject({ status: "ok", threadBindingReady: true });
|
||||
});
|
||||
|
||||
it("defaults thread-bound subagent spawn to disabled when unset", async () => {
|
||||
const handlers = registerHandlersForTest({
|
||||
channels: {
|
||||
discord: {
|
||||
threadBindings: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const handler = handlers.get("subagent_spawning");
|
||||
if (!handler) {
|
||||
throw new Error("expected subagent_spawning hook handler");
|
||||
}
|
||||
|
||||
const result = await handler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
requester: {
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
to: "channel:123",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ status: "error" });
|
||||
});
|
||||
|
||||
it("no-ops when thread binding is requested on non-discord channel", async () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const handler = handlers.get("subagent_spawning");
|
||||
if (!handler) {
|
||||
throw new Error("expected subagent_spawning hook handler");
|
||||
}
|
||||
|
||||
const result = await handler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "signal",
|
||||
to: "+123",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled();
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns error when thread bind fails", async () => {
|
||||
hookMocks.autoBindSpawnedDiscordSubagent.mockResolvedValueOnce(null);
|
||||
const handlers = registerHandlersForTest();
|
||||
const handler = handlers.get("subagent_spawning");
|
||||
if (!handler) {
|
||||
throw new Error("expected subagent_spawning hook handler");
|
||||
}
|
||||
|
||||
const result = await handler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
mode: "session",
|
||||
requester: {
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
to: "channel:123",
|
||||
},
|
||||
threadRequested: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ status: "error" });
|
||||
const errorText = (result as { error?: string }).error ?? "";
|
||||
expect(errorText).toMatch(/unable to create or bind/i);
|
||||
});
|
||||
|
||||
it("unbinds thread routing on subagent_ended", () => {
|
||||
const handlers = registerHandlersForTest();
|
||||
const handler = handlers.get("subagent_ended");
|
||||
if (!handler) {
|
||||
throw new Error("expected subagent_ended hook handler");
|
||||
}
|
||||
|
||||
handler(
|
||||
{
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
targetKind: "subagent",
|
||||
reason: "subagent-complete",
|
||||
sendFarewell: true,
|
||||
accountId: "work",
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(hookMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledTimes(1);
|
||||
expect(hookMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledWith({
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
accountId: "work",
|
||||
targetKind: "subagent",
|
||||
reason: "subagent-complete",
|
||||
sendFarewell: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves delivery target from matching bound thread", () => {
|
||||
hookMocks.listThreadBindingsBySessionKey.mockReturnValueOnce([
|
||||
{ accountId: "work", threadId: "777" },
|
||||
]);
|
||||
const handlers = registerHandlersForTest();
|
||||
const handler = handlers.get("subagent_delivery_target");
|
||||
if (!handler) {
|
||||
throw new Error("expected subagent_delivery_target hook handler");
|
||||
}
|
||||
|
||||
const result = handler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: {
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
to: "channel:123",
|
||||
threadId: "777",
|
||||
},
|
||||
childRunId: "run-1",
|
||||
spawnMode: "session",
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(hookMocks.listThreadBindingsBySessionKey).toHaveBeenCalledWith({
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
accountId: "work",
|
||||
targetKind: "subagent",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
origin: {
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
to: "channel:777",
|
||||
threadId: "777",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps original routing when delivery target is ambiguous", () => {
|
||||
hookMocks.listThreadBindingsBySessionKey.mockReturnValueOnce([
|
||||
{ accountId: "work", threadId: "777" },
|
||||
{ accountId: "work", threadId: "888" },
|
||||
]);
|
||||
const handlers = registerHandlersForTest();
|
||||
const handler = handlers.get("subagent_delivery_target");
|
||||
if (!handler) {
|
||||
throw new Error("expected subagent_delivery_target hook handler");
|
||||
}
|
||||
|
||||
const result = handler(
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: {
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
to: "channel:123",
|
||||
},
|
||||
childRunId: "run-1",
|
||||
spawnMode: "session",
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
152
extensions/discord/src/subagent-hooks.ts
Normal file
152
extensions/discord/src/subagent-hooks.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
autoBindSpawnedDiscordSubagent,
|
||||
listThreadBindingsBySessionKey,
|
||||
resolveDiscordAccount,
|
||||
unbindThreadBindingsBySessionKey,
|
||||
} from "openclaw/plugin-sdk";
|
||||
|
||||
function summarizeError(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
if (typeof err === "string") {
|
||||
return err;
|
||||
}
|
||||
return "error";
|
||||
}
|
||||
|
||||
export function registerDiscordSubagentHooks(api: OpenClawPluginApi) {
|
||||
const resolveThreadBindingFlags = (accountId?: string) => {
|
||||
const account = resolveDiscordAccount({
|
||||
cfg: api.config,
|
||||
accountId,
|
||||
});
|
||||
const baseThreadBindings = api.config.channels?.discord?.threadBindings;
|
||||
const accountThreadBindings =
|
||||
api.config.channels?.discord?.accounts?.[account.accountId]?.threadBindings;
|
||||
return {
|
||||
enabled:
|
||||
accountThreadBindings?.enabled ??
|
||||
baseThreadBindings?.enabled ??
|
||||
api.config.session?.threadBindings?.enabled ??
|
||||
true,
|
||||
spawnSubagentSessions:
|
||||
accountThreadBindings?.spawnSubagentSessions ??
|
||||
baseThreadBindings?.spawnSubagentSessions ??
|
||||
false,
|
||||
};
|
||||
};
|
||||
|
||||
api.on("subagent_spawning", async (event) => {
|
||||
if (!event.threadRequested) {
|
||||
return;
|
||||
}
|
||||
const channel = event.requester?.channel?.trim().toLowerCase();
|
||||
if (channel !== "discord") {
|
||||
// Ignore non-Discord channels so channel-specific plugins can handle
|
||||
// their own thread/session provisioning without Discord blocking them.
|
||||
return;
|
||||
}
|
||||
const threadBindingFlags = resolveThreadBindingFlags(event.requester?.accountId);
|
||||
if (!threadBindingFlags.enabled) {
|
||||
return {
|
||||
status: "error" as const,
|
||||
error:
|
||||
"Discord thread bindings are disabled (set channels.discord.threadBindings.enabled=true to override for this account, or session.threadBindings.enabled=true globally).",
|
||||
};
|
||||
}
|
||||
if (!threadBindingFlags.spawnSubagentSessions) {
|
||||
return {
|
||||
status: "error" as const,
|
||||
error:
|
||||
"Discord thread-bound subagent spawns are disabled for this account (set channels.discord.threadBindings.spawnSubagentSessions=true to enable).",
|
||||
};
|
||||
}
|
||||
try {
|
||||
const binding = await autoBindSpawnedDiscordSubagent({
|
||||
accountId: event.requester?.accountId,
|
||||
channel: event.requester?.channel,
|
||||
to: event.requester?.to,
|
||||
threadId: event.requester?.threadId,
|
||||
childSessionKey: event.childSessionKey,
|
||||
agentId: event.agentId,
|
||||
label: event.label,
|
||||
boundBy: "system",
|
||||
});
|
||||
if (!binding) {
|
||||
return {
|
||||
status: "error" as const,
|
||||
error:
|
||||
"Unable to create or bind a Discord thread for this subagent session. Session mode is unavailable for this target.",
|
||||
};
|
||||
}
|
||||
return { status: "ok" as const, threadBindingReady: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
status: "error" as const,
|
||||
error: `Discord thread bind failed: ${summarizeError(err)}`,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
api.on("subagent_ended", (event) => {
|
||||
unbindThreadBindingsBySessionKey({
|
||||
targetSessionKey: event.targetSessionKey,
|
||||
accountId: event.accountId,
|
||||
targetKind: event.targetKind,
|
||||
reason: event.reason,
|
||||
sendFarewell: event.sendFarewell,
|
||||
});
|
||||
});
|
||||
|
||||
api.on("subagent_delivery_target", (event) => {
|
||||
if (!event.expectsCompletionMessage) {
|
||||
return;
|
||||
}
|
||||
const requesterChannel = event.requesterOrigin?.channel?.trim().toLowerCase();
|
||||
if (requesterChannel !== "discord") {
|
||||
return;
|
||||
}
|
||||
const requesterAccountId = event.requesterOrigin?.accountId?.trim();
|
||||
const requesterThreadId =
|
||||
event.requesterOrigin?.threadId != null && event.requesterOrigin.threadId !== ""
|
||||
? String(event.requesterOrigin.threadId).trim()
|
||||
: "";
|
||||
const bindings = listThreadBindingsBySessionKey({
|
||||
targetSessionKey: event.childSessionKey,
|
||||
...(requesterAccountId ? { accountId: requesterAccountId } : {}),
|
||||
targetKind: "subagent",
|
||||
});
|
||||
if (bindings.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let binding: (typeof bindings)[number] | undefined;
|
||||
if (requesterThreadId) {
|
||||
binding = bindings.find((entry) => {
|
||||
if (entry.threadId !== requesterThreadId) {
|
||||
return false;
|
||||
}
|
||||
if (requesterAccountId && entry.accountId !== requesterAccountId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
if (!binding && bindings.length === 1) {
|
||||
binding = bindings[0];
|
||||
}
|
||||
if (!binding) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
origin: {
|
||||
channel: "discord",
|
||||
accountId: binding.accountId,
|
||||
to: `channel:${binding.threadId}`,
|
||||
threadId: binding.threadId,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user