Skip to content

Commit 4cae1c5

Browse files
committed
feat(n8n): propagate originating-conversation routing into workflow generator
When the workflow is generated from inside a platform conversation (e.g. a Discord DM or a Telegram chat), surface the originating channel or chat to the LLM as a `## Runtime Facts` line so the user can say "post the result back to this channel" and the generated send node targets the right ID without naming it. End-to-end wiring: - `client-types-chat.ts`: extend `N8nWorkflowGenerateRequest` with optional `bridgeConversationId`. AutomationsView already had the id in scope (it uses it to bind the workflow to the originating conversation); now also forwards it on the generation request. - `n8n-routes.ts`: when `bridgeConversationId` is present, read the originating conversation's tail inbound message metadata via `runtime.getMemories({ roomId, tableName: "messages", count: 12 })`, derive a `TriggerContext` (Discord channelId/guildId, Telegram chatId/threadId, Slack channelId/teamId), and thread it into `service.generateWorkflowDraft(prompt, { triggerContext })`. The helper reads BOTH the canonical `metadata.discord.{channelId,guildId}` sub-object AND the legacy flat `discordChannelId` / `discordServerId` fields — pre-existing schema gap; canonical wins when present, flat is the fallback so nothing today breaks. - `n8n-runtime-context-provider.ts`: extend `RuntimeContextProviderInput` to accept the trigger context, render it as a fact line: This workflow was prompted from a Discord conversation in #general (id 9876543210) within "Cozy Devs" (id 1234567890). When the user references "this channel" or "back to here", target that channel ID. Same pattern for Telegram chats and Slack channels. Empty / missing routing data → no fact line. Backward compatibility: - Routes still work without `bridgeConversationId` (no triggerContext threading, baseline behavior). - Plugin still works with hosts that don't pass triggerContext (the optional `opts` arg on `generateWorkflowDraft` is unused — see elizaos-plugins/plugin-n8n-workflow#26). Out of scope (follow-up): - Persist `originChannelContext` on the workflow's conversation metadata for re-runs without a fresh inbound message. - Switch upstream plugin-discord/telegram from flat metadata fields to the canonical nested `metadata.discord.{channelId,guildId,messageId}` shape. Depends on: elizaos-plugins/plugin-n8n-workflow#26 (TriggerContext on RuntimeContextProviderInput) at runtime; this PR's host code compiles and falls through cleanly until the plugin upgrade is wired.
1 parent cafdf62 commit 4cae1c5

4 files changed

Lines changed: 218 additions & 2 deletions

File tree

packages/app-core/src/api/client-types-chat.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,4 +505,12 @@ export interface N8nWorkflowGenerateRequest {
505505
prompt: string;
506506
name?: string;
507507
workflowId?: string;
508+
/**
509+
* Optional originating conversation id. When present, the server reads
510+
* the conversation's tail inbound message metadata and threads platform
511+
* routing (Discord channelId/guildId, Telegram chatId, etc.) into the
512+
* workflow generator so the LLM can target "this channel" / "back to
513+
* here" without the user naming an ID.
514+
*/
515+
bridgeConversationId?: string;
508516
}

packages/app-core/src/api/n8n-routes.ts

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,142 @@ function readOptionalNumber(
610610
: undefined;
611611
}
612612

613+
/**
614+
* Shape of the routing block we hand to the n8n workflow service so the
615+
* generator can target "this channel" / "back to here" without the user
616+
* naming an ID. Mirrors the upstream `TriggerContext` in
617+
* `@elizaos/plugin-n8n-workflow` — duplicated here so this route doesn't
618+
* import from the plugin (the host already has its own copy in the
619+
* runtime context provider, and the LLM ultimately reads it as a
620+
* `## Runtime Facts` line, not via the plugin's prompt builder).
621+
*/
622+
interface TriggerContext {
623+
source?: string;
624+
discord?: { channelId?: string; guildId?: string; threadId?: string };
625+
telegram?: { chatId?: string | number; threadId?: string | number };
626+
slack?: { channelId?: string; teamId?: string };
627+
resolvedNames?: { channel?: string; server?: string };
628+
}
629+
630+
/**
631+
* Read the originating conversation's tail inbound message metadata and
632+
* derive a `TriggerContext`. Reads both the canonical
633+
* `metadata.discord.{channelId,guildId,messageId}` /
634+
* `metadata.telegram.{chatId,threadId}` blocks AND the flat
635+
* `discordChannelId` / `discordServerId` / `discordMessageId` fields the
636+
* upstream Discord plugin currently writes (pre-existing schema gap —
637+
* canonical wins when present, flat is the fallback so nothing today
638+
* breaks).
639+
*
640+
* Returns `undefined` when the conversation has no inbound platform
641+
* metadata or the runtime can't read memories.
642+
*/
643+
async function buildTriggerContextFromConversation(
644+
runtime: AgentRuntime | undefined,
645+
roomId: string,
646+
): Promise<TriggerContext | undefined> {
647+
if (!runtime || typeof runtime.getMemories !== "function") return undefined;
648+
let memories: Array<{
649+
entityId?: string;
650+
metadata?: Record<string, unknown>;
651+
}>;
652+
try {
653+
memories = (await runtime.getMemories({
654+
roomId: roomId as never,
655+
tableName: "messages",
656+
count: 12,
657+
} as Parameters<typeof runtime.getMemories>[0])) as Array<{
658+
entityId?: string;
659+
metadata?: Record<string, unknown>;
660+
}>;
661+
} catch (err) {
662+
logger.debug?.(
663+
`[n8n-routes] buildTriggerContextFromConversation: getMemories threw: ${
664+
err instanceof Error ? err.message : String(err)
665+
}`,
666+
);
667+
return undefined;
668+
}
669+
if (!Array.isArray(memories) || memories.length === 0) return undefined;
670+
671+
// Tail inbound = most recent memory whose entityId is NOT the agent.
672+
// `runtime.getMemories` typically returns most-recent-first; defensively
673+
// handle either order.
674+
const inbound = memories.find(
675+
(m) => m.entityId && m.entityId !== runtime.agentId,
676+
);
677+
if (!inbound?.metadata) return undefined;
678+
679+
const meta = inbound.metadata as Record<string, unknown>;
680+
const discord = (meta.discord ?? {}) as Record<string, unknown>;
681+
const telegram = (meta.telegram ?? {}) as Record<string, unknown>;
682+
const slack = (meta.slack ?? {}) as Record<string, unknown>;
683+
684+
// Canonical wins; flat fields are the legacy de-facto shape.
685+
const discordChannelId =
686+
(typeof discord.channelId === "string" ? discord.channelId : undefined) ??
687+
(typeof meta.discordChannelId === "string"
688+
? meta.discordChannelId
689+
: undefined);
690+
const discordGuildId =
691+
(typeof discord.guildId === "string" ? discord.guildId : undefined) ??
692+
(typeof meta.discordServerId === "string"
693+
? meta.discordServerId
694+
: undefined);
695+
const discordThreadId =
696+
typeof discord.threadId === "string" ? discord.threadId : undefined;
697+
698+
const telegramChatId =
699+
(typeof telegram.chatId === "string" || typeof telegram.chatId === "number"
700+
? telegram.chatId
701+
: undefined) ??
702+
(typeof meta.fromId === "string" || typeof meta.fromId === "number"
703+
? (meta.fromId as string | number)
704+
: undefined);
705+
const telegramThreadId =
706+
typeof telegram.threadId === "string" ||
707+
typeof telegram.threadId === "number"
708+
? (telegram.threadId as string | number)
709+
: undefined;
710+
711+
const slackChannelId =
712+
typeof slack.channelId === "string" ? slack.channelId : undefined;
713+
const slackTeamId =
714+
typeof slack.teamId === "string" ? slack.teamId : undefined;
715+
716+
if (discordChannelId) {
717+
return {
718+
source: "discord",
719+
discord: {
720+
...(discordChannelId ? { channelId: discordChannelId } : {}),
721+
...(discordGuildId ? { guildId: discordGuildId } : {}),
722+
...(discordThreadId ? { threadId: discordThreadId } : {}),
723+
},
724+
};
725+
}
726+
if (telegramChatId !== undefined) {
727+
return {
728+
source: "telegram",
729+
telegram: {
730+
chatId: telegramChatId,
731+
...(telegramThreadId !== undefined
732+
? { threadId: telegramThreadId }
733+
: {}),
734+
},
735+
};
736+
}
737+
if (slackChannelId) {
738+
return {
739+
source: "slack",
740+
slack: {
741+
channelId: slackChannelId,
742+
...(slackTeamId ? { teamId: slackTeamId } : {}),
743+
},
744+
};
745+
}
746+
return undefined;
747+
}
748+
613749
function readPosition(value: unknown): [number, number] | null {
614750
return Array.isArray(value) &&
615751
value.length >= 2 &&
@@ -1130,10 +1266,14 @@ async function handleGenerateWorkflow(ctx: N8nRouteContext): Promise<boolean> {
11301266

11311267
const name = readOptionalString(body, "name");
11321268
const workflowId = readOptionalString(body, "workflowId");
1269+
const bridgeConversationId = readOptionalString(body, "bridgeConversationId");
11331270

11341271
const service = ctx.runtime?.getService?.("n8n_workflow") as
11351272
| {
1136-
generateWorkflowDraft?: (prompt: string) => Promise<{
1273+
generateWorkflowDraft?: (
1274+
prompt: string,
1275+
opts?: { triggerContext?: TriggerContext },
1276+
) => Promise<{
11371277
id?: string;
11381278
[k: string]: unknown;
11391279
}>;
@@ -1158,7 +1298,14 @@ async function handleGenerateWorkflow(ctx: N8nRouteContext): Promise<boolean> {
11581298
return true;
11591299
}
11601300

1161-
const draft = await service.generateWorkflowDraft(prompt);
1301+
const triggerContext = bridgeConversationId
1302+
? await buildTriggerContextFromConversation(ctx.runtime, bridgeConversationId)
1303+
: undefined;
1304+
1305+
const draft = await service.generateWorkflowDraft(
1306+
prompt,
1307+
triggerContext ? { triggerContext } : undefined,
1308+
);
11621309
if (name?.trim()) {
11631310
(draft as Record<string, unknown>).name = name.trim();
11641311
}

packages/app-core/src/components/pages/AutomationsView.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4596,6 +4596,7 @@ function AutomationsLayout() {
45964596
prompt,
45974597
...(title?.trim() ? { name: title.trim() } : {}),
45984598
...(workflowId ? { workflowId } : {}),
4599+
...(bridgeConversationId ? { bridgeConversationId } : {}),
45994600
});
46004601
if (isMissingCredentialsResponse(result)) {
46014602
setMissingCredentials(result.missingCredentials);

packages/app-core/src/services/n8n-runtime-context-provider.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,28 @@ interface PluginNodeDefinition {
9595
credentials?: Array<{ name: string; required?: boolean }>;
9696
}
9797

98+
/**
99+
* Originating-conversation routing context. Hosts pass this when the workflow
100+
* is being generated from inside a platform conversation (e.g. a Discord DM),
101+
* so the LLM can target "this channel" / "back to me" without the user
102+
* naming an ID.
103+
*
104+
* Mirrors the shape introduced upstream in `@elizaos/plugin-n8n-workflow` —
105+
* the host duplicates the type so it doesn't need to import from the plugin.
106+
*/
107+
export interface TriggerContext {
108+
source?: string;
109+
discord?: { channelId?: string; guildId?: string; threadId?: string };
110+
telegram?: { chatId?: string | number; threadId?: string | number };
111+
slack?: { channelId?: string; teamId?: string };
112+
resolvedNames?: { channel?: string; server?: string };
113+
}
114+
98115
interface RuntimeContextProviderInput {
99116
userId: string;
100117
relevantNodes: PluginNodeDefinition[];
101118
relevantCredTypes: string[];
119+
triggerContext?: TriggerContext;
102120
}
103121

104122
/**
@@ -209,6 +227,40 @@ export interface MiladyN8nRuntimeContextProviderHandle {
209227
/** Re-exported for tests + runtime helpers. */
210228
export { SERVICE_TYPE as N8N_RUNTIME_CONTEXT_PROVIDER_SERVICE_TYPE };
211229

230+
/**
231+
* Render a trigger-source fact line the LLM can read as part of the
232+
* `## Runtime Facts` block. Returns `undefined` when the trigger context
233+
* is empty / has no actionable platform routing info.
234+
*/
235+
function formatTriggerContextFact(
236+
ctx: TriggerContext | undefined,
237+
): string | undefined {
238+
if (!ctx) return undefined;
239+
const channelName = ctx.resolvedNames?.channel;
240+
const serverName = ctx.resolvedNames?.server;
241+
242+
if (ctx.discord?.channelId) {
243+
const channelLabel = channelName ? `#${channelName}` : "the channel";
244+
const serverPart = ctx.discord.guildId
245+
? serverName
246+
? ` within "${serverName}" (id ${ctx.discord.guildId})`
247+
: ` within guild id ${ctx.discord.guildId}`
248+
: "";
249+
return `This workflow was prompted from a Discord conversation in ${channelLabel} (id ${ctx.discord.channelId})${serverPart}. When the user references "this channel" or "back to here", target that channel ID.`;
250+
}
251+
if (ctx.telegram?.chatId !== undefined) {
252+
return `This workflow was prompted from a Telegram chat (id ${ctx.telegram.chatId}). When the user references "this chat" or "back to here", target that chat ID.`;
253+
}
254+
if (ctx.slack?.channelId) {
255+
const teamPart = ctx.slack.teamId ? ` in team ${ctx.slack.teamId}` : "";
256+
return `This workflow was prompted from a Slack channel (id ${ctx.slack.channelId})${teamPart}. When the user references "this channel" or "back to here", target that channel ID.`;
257+
}
258+
if (ctx.source) {
259+
return `This workflow was prompted from a ${ctx.source} conversation.`;
260+
}
261+
return undefined;
262+
}
263+
212264
export function startMiladyN8nRuntimeContextProvider(
213265
runtime: AgentRuntime,
214266
options: MiladyN8nRuntimeContextProviderOptions,
@@ -389,6 +441,14 @@ export function startMiladyN8nRuntimeContextProvider(
389441
}
390442
}
391443

444+
// Originating-conversation routing fact. Surfaced regardless of which
445+
// nodes are in scope — the user might say "post the result back here"
446+
// and the relevant node search has no way to anchor that intent.
447+
const triggerFact = formatTriggerContextFact(input.triggerContext);
448+
if (triggerFact) {
449+
facts.push(triggerFact);
450+
}
451+
392452
return { supportedCredentials, facts };
393453
};
394454

0 commit comments

Comments
 (0)