Skip to content

Commit 972d9bd

Browse files
authored
feat(app-core): connector-target-catalog service (Discord) (#7315)
Adds a cross-connector target enumeration framework so the n8n clarification UI can render quick-pick servers/channels/recipients without making the user paste raw IDs. Discord is the only wired source in this slice; Slack, Telegram, and Gmail are planned 2.1+ stacks that drop in behind the same `ConnectorTargetCatalog` interface. The Discord REST + 5-minute cache that previously lived inline in n8n-runtime-context-provider is extracted into a shared `discord-target-source.ts` exposing two functions: - fetchDiscordEnumeration(token, opts) returns a structured `DiscordEnumerationResult[]` (one entry per guild, with text channels or a `channelsError` marker on degraded fetches). - formatDiscordEnumerationAsFacts(results) renders the same human-readable LLM-prompt fact strings the runtime-context provider has always emitted, so prompt output is byte-identical after the refactor. n8n-runtime-context-provider now consumes both helpers. Behavior is unchanged; the existing tests still pass without modification. A new `discordCache?: DiscordSourceCache` option lets a host wire one shared cache instance across both services. connector-target-catalog.ts wraps the same source and exposes: - TargetGroup { platform, groupId, groupName, targets[] } - ConnectorTargetCatalog.listGroups({ platform?, groupId? }) - createMiladyConnectorTargetCatalog({ getConfig, fetchImpl?, now?, discordCache?, logger? }) The runtime registers it as `connector_target_catalog` in runtime.services, sharing the same Discord cache as the runtime-context provider so a "generate" call that primed the cache incurs zero extra REST cost when the user later picks a channel. Tests cover: multi-guild enumeration, per-guild groupId narrowing, no-token short-circuit, REST 401 silent degrade, channel-fetch 429 returns guild with empty targets, non-discord platform filter, and shared-cache hit avoidance.
1 parent 08eac44 commit 972d9bd

6 files changed

Lines changed: 787 additions & 95 deletions

File tree

packages/app-core/src/runtime/eliza.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,7 @@ async function repairRuntimeAfterBoot(
578578
// service as advisory; if it isn't registered the prompt simply omits the
579579
// facts/credentials sections.
580580
await ensureN8nRuntimeContextProvider(runtime);
581+
await ensureConnectorTargetCatalog(runtime);
581582

582583
return runtime;
583584
}
@@ -610,6 +611,22 @@ let _triggerEventBridge: { stop: () => void } | null = null;
610611
// does not survive into the fresh runtime's services map.
611612
let _n8nRuntimeContextProvider: { stop: () => void } | null = null;
612613

614+
// Shared Discord enumeration cache so the runtime-context provider (called
615+
// at generate time) and the connector-target-catalog (called at quick-pick
616+
// resolve time) hit one 5-minute REST window instead of two. Reset whenever
617+
// the runtime-context provider is re-created so a hot-reload cannot leak
618+
// stale guild/channel state into the fresh runtime.
619+
let _discordEnumerationCache: import(
620+
"../services/discord-target-source"
621+
).DiscordSourceCache | null = null;
622+
623+
// Module-level handle for the connector-target-catalog service. Reset across
624+
// hot-reloads with the same cadence as _n8nRuntimeContextProvider so both
625+
// services share a single Discord enumeration cache.
626+
let _connectorTargetCatalog: { stop: () => void } | null = null;
627+
628+
const CONNECTOR_TARGET_CATALOG_SERVICE_TYPE = "connector_target_catalog";
629+
613630
async function ensureN8nAuthBridge(runtime: AgentRuntime): Promise<void> {
614631
if (_n8nAuthBridge) {
615632
try {
@@ -742,6 +759,12 @@ async function ensureN8nRuntimeContextProvider(
742759
}
743760
_n8nRuntimeContextProvider = null;
744761
}
762+
// Fresh cache on every (re)build — the catalog service picks up the same
763+
// instance below in ensureConnectorTargetCatalog.
764+
const { createDiscordSourceCache } = await import(
765+
"../services/discord-target-source.js"
766+
);
767+
_discordEnumerationCache = createDiscordSourceCache();
745768
try {
746769
const { startElizaN8nRuntimeContextProvider } = await import(
747770
"../services/n8n-runtime-context-provider.js"
@@ -767,6 +790,7 @@ async function ensureN8nRuntimeContextProvider(
767790
_n8nRuntimeContextProvider = startElizaN8nRuntimeContextProvider(runtime, {
768791
getConfig: () => loadElizaConfig(),
769792
credProvider,
793+
discordCache: _discordEnumerationCache ?? undefined,
770794
});
771795
logger.info("[eliza] n8n runtime-context provider registered");
772796
} catch (err) {
@@ -778,6 +802,51 @@ async function ensureN8nRuntimeContextProvider(
778802
}
779803
}
780804

805+
async function ensureConnectorTargetCatalog(
806+
runtime: AgentRuntime,
807+
): Promise<void> {
808+
if (_connectorTargetCatalog) {
809+
try {
810+
_connectorTargetCatalog.stop();
811+
} catch {
812+
/* ignore */
813+
}
814+
_connectorTargetCatalog = null;
815+
}
816+
try {
817+
const { createMiladyConnectorTargetCatalog } = await import(
818+
"../services/connector-target-catalog.js"
819+
);
820+
const catalog = createMiladyConnectorTargetCatalog({
821+
getConfig: () => loadElizaConfig(),
822+
discordCache: _discordEnumerationCache ?? undefined,
823+
logger: { warn: runtime.logger.warn?.bind(runtime.logger) },
824+
});
825+
runtime.services.set(
826+
CONNECTOR_TARGET_CATALOG_SERVICE_TYPE as never,
827+
[catalog as never],
828+
);
829+
_connectorTargetCatalog = {
830+
stop: () => {
831+
try {
832+
runtime.services.delete(
833+
CONNECTOR_TARGET_CATALOG_SERVICE_TYPE as never,
834+
);
835+
} catch {
836+
/* ignore */
837+
}
838+
},
839+
};
840+
logger.info("[eliza] connector-target-catalog registered");
841+
} catch (err) {
842+
logger.warn(
843+
`[eliza] Failed to register connector-target-catalog: ${
844+
err instanceof Error ? err.message : String(err)
845+
}`,
846+
);
847+
}
848+
}
849+
781850
// Module-level Telegraf bot reference for lifecycle management across restarts.
782851
let _telegramBot: { stop: (reason?: string) => void } | null = null;
783852

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
3+
import {
4+
type ConnectorConfigLike,
5+
createMiladyConnectorTargetCatalog,
6+
} from "./connector-target-catalog";
7+
import { createDiscordSourceCache } from "./discord-target-source";
8+
9+
afterEach(() => {
10+
vi.clearAllMocks();
11+
});
12+
13+
function makeFetch(
14+
routes: Record<string, { ok?: boolean; status?: number; body?: unknown }>,
15+
): { fn: typeof fetch; mock: ReturnType<typeof vi.fn> } {
16+
const mock = vi.fn(async (url: string) => {
17+
for (const [needle, response] of Object.entries(routes)) {
18+
if (url.includes(needle)) {
19+
return {
20+
ok: response.ok ?? true,
21+
status: response.status ?? 200,
22+
json: async () => response.body ?? [],
23+
} as unknown as Response;
24+
}
25+
}
26+
throw new Error(`unmocked fetch ${url}`);
27+
});
28+
return { fn: mock as unknown as typeof fetch, mock };
29+
}
30+
31+
describe("MiladyConnectorTargetCatalog — Discord", () => {
32+
it("returns one TargetGroup per Discord guild with text channels as targets", async () => {
33+
const config: ConnectorConfigLike = {
34+
connectors: { discord: { enabled: true, token: "tok" } },
35+
};
36+
const { fn } = makeFetch({
37+
"/users/@me/guilds": {
38+
body: [
39+
{ id: "g1", name: "Cozy Devs" },
40+
{ id: "g2", name: "Other" },
41+
],
42+
},
43+
"/guilds/g1/channels": {
44+
body: [
45+
{ id: "c-general", name: "general", type: 0 },
46+
{ id: "c-voice", name: "voice", type: 2 },
47+
],
48+
},
49+
"/guilds/g2/channels": {
50+
body: [{ id: "c-only", name: "only-text", type: 0 }],
51+
},
52+
});
53+
const catalog = createMiladyConnectorTargetCatalog({
54+
getConfig: () => config,
55+
fetchImpl: fn,
56+
});
57+
const groups = await catalog.listGroups();
58+
expect(groups).toEqual([
59+
{
60+
platform: "discord",
61+
groupId: "g1",
62+
groupName: "Cozy Devs",
63+
targets: [{ id: "c-general", name: "general", kind: "channel" }],
64+
},
65+
{
66+
platform: "discord",
67+
groupId: "g2",
68+
groupName: "Other",
69+
targets: [{ id: "c-only", name: "only-text", kind: "channel" }],
70+
},
71+
]);
72+
});
73+
74+
it("narrows to a single guild when groupId is supplied", async () => {
75+
const config: ConnectorConfigLike = {
76+
connectors: { discord: { token: "tok" } },
77+
};
78+
const { fn } = makeFetch({
79+
"/users/@me/guilds": {
80+
body: [
81+
{ id: "g1", name: "Cozy Devs" },
82+
{ id: "g2", name: "Other" },
83+
],
84+
},
85+
"/guilds/g1/channels": {
86+
body: [{ id: "c", name: "general", type: 0 }],
87+
},
88+
"/guilds/g2/channels": { body: [] },
89+
});
90+
const catalog = createMiladyConnectorTargetCatalog({
91+
getConfig: () => config,
92+
fetchImpl: fn,
93+
});
94+
const groups = await catalog.listGroups({
95+
platform: "discord",
96+
groupId: "g1",
97+
});
98+
expect(groups).toHaveLength(1);
99+
expect(groups[0].groupId).toBe("g1");
100+
});
101+
102+
it("returns [] when no Discord token is configured", async () => {
103+
const fetchImpl = vi.fn();
104+
const catalog = createMiladyConnectorTargetCatalog({
105+
getConfig: () => ({ connectors: {} }),
106+
fetchImpl: fetchImpl as unknown as typeof fetch,
107+
});
108+
expect(await catalog.listGroups()).toEqual([]);
109+
expect(fetchImpl).not.toHaveBeenCalled();
110+
});
111+
112+
it("returns [] when Discord guilds REST returns 401 (silent degrade)", async () => {
113+
const config: ConnectorConfigLike = {
114+
connectors: { discord: { token: "bad" } },
115+
};
116+
const { fn } = makeFetch({
117+
"/users/@me/guilds": { ok: false, status: 401 },
118+
});
119+
const catalog = createMiladyConnectorTargetCatalog({
120+
getConfig: () => config,
121+
fetchImpl: fn,
122+
});
123+
expect(await catalog.listGroups()).toEqual([]);
124+
});
125+
126+
it("returns guild with empty targets when channel fetch is rate-limited", async () => {
127+
const config: ConnectorConfigLike = {
128+
connectors: { discord: { token: "tok" } },
129+
};
130+
const { fn } = makeFetch({
131+
"/users/@me/guilds": { body: [{ id: "g", name: "G" }] },
132+
"/guilds/g/channels": { ok: false, status: 429 },
133+
});
134+
const catalog = createMiladyConnectorTargetCatalog({
135+
getConfig: () => config,
136+
fetchImpl: fn,
137+
});
138+
const groups = await catalog.listGroups();
139+
expect(groups).toEqual([
140+
{ platform: "discord", groupId: "g", groupName: "G", targets: [] },
141+
]);
142+
});
143+
144+
it("filters by platform when a non-discord platform is requested", async () => {
145+
const config: ConnectorConfigLike = {
146+
connectors: { discord: { token: "tok" } },
147+
};
148+
const fetchImpl = vi.fn();
149+
const catalog = createMiladyConnectorTargetCatalog({
150+
getConfig: () => config,
151+
fetchImpl: fetchImpl as unknown as typeof fetch,
152+
});
153+
expect(await catalog.listGroups({ platform: "slack" })).toEqual([]);
154+
expect(fetchImpl).not.toHaveBeenCalled();
155+
});
156+
157+
it("shares its Discord cache when one is supplied", async () => {
158+
const config: ConnectorConfigLike = {
159+
connectors: { discord: { token: "tok" } },
160+
};
161+
const { fn, mock } = makeFetch({
162+
"/users/@me/guilds": { body: [{ id: "g", name: "G" }] },
163+
"/guilds/g/channels": { body: [] },
164+
});
165+
const cache = createDiscordSourceCache();
166+
const catalog = createMiladyConnectorTargetCatalog({
167+
getConfig: () => config,
168+
fetchImpl: fn,
169+
discordCache: cache,
170+
});
171+
await catalog.listGroups();
172+
const callsAfterFirst = mock.mock.calls.length;
173+
expect(callsAfterFirst).toBe(2);
174+
await catalog.listGroups();
175+
expect(mock.mock.calls.length).toBe(callsAfterFirst);
176+
});
177+
});

0 commit comments

Comments
 (0)