Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/app-core/src/api/client-types-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,4 +505,12 @@ export interface N8nWorkflowGenerateRequest {
prompt: string;
name?: string;
workflowId?: string;
/**
* Optional originating conversation id. When present, the server reads
* the conversation's tail inbound message metadata and threads platform
* routing (Discord channelId/guildId, Telegram chatId, etc.) into the
* workflow generator so the LLM can target "this channel" / "back to
* here" without the user naming an ID.
*/
bridgeConversationId?: string;
}
155 changes: 153 additions & 2 deletions packages/app-core/src/api/n8n-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,146 @@ function readOptionalNumber(
: undefined;
}

/**
* Shape of the routing block we hand to the n8n workflow service so the
* generator can target "this channel" / "back to here" without the user
* naming an ID. Mirrors the upstream `TriggerContext` in
* `@elizaos/plugin-n8n-workflow` — duplicated here so this route doesn't
* import from the plugin (the host already has its own copy in the
* runtime context provider, and the LLM ultimately reads it as a
* `## Runtime Facts` line, not via the plugin's prompt builder).
*/
interface TriggerContext {
source?: string;
discord?: { channelId?: string; guildId?: string; threadId?: string };
telegram?: { chatId?: string | number; threadId?: string | number };
slack?: { channelId?: string; teamId?: string };
resolvedNames?: { channel?: string; server?: string };
}

/**
* Read the originating conversation's tail inbound message metadata and
* derive a `TriggerContext`. Reads both the canonical
* `metadata.discord.{channelId,guildId,messageId}` /
* `metadata.telegram.{chatId,threadId}` blocks AND the flat
* `discordChannelId` / `discordServerId` / `discordMessageId` fields the
* upstream Discord plugin currently writes (pre-existing schema gap —
* canonical wins when present, flat is the fallback so nothing today
* breaks).
*
* Returns `undefined` when the conversation has no inbound platform
* metadata or the runtime can't read memories.
*/
async function buildTriggerContextFromConversation(
runtime: AgentRuntime | undefined,
roomId: string,
): Promise<TriggerContext | undefined> {
if (!runtime || typeof runtime.getMemories !== "function") return undefined;
let memories: Array<{
entityId?: string;
metadata?: Record<string, unknown>;
}>;
try {
memories = (await runtime.getMemories({
roomId: roomId as never,
tableName: "messages",
count: 12,
} as Parameters<typeof runtime.getMemories>[0])) as Array<{
entityId?: string;
metadata?: Record<string, unknown>;
}>;
} catch (err) {
logger.debug?.(
`[n8n-routes] buildTriggerContextFromConversation: getMemories threw: ${
err instanceof Error ? err.message : String(err)
}`,
);
return undefined;
}
if (!Array.isArray(memories) || memories.length === 0) return undefined;

// Tail inbound = most recent memory whose entityId is NOT the agent.
// `runtime.getMemories` typically returns most-recent-first; defensively
// handle either order.
const inbound = memories.find(
(m) => m.entityId && m.entityId !== runtime.agentId,
);
if (!inbound?.metadata) return undefined;

const meta = inbound.metadata as Record<string, unknown>;
const discord = (meta.discord ?? {}) as Record<string, unknown>;
const telegram = (meta.telegram ?? {}) as Record<string, unknown>;
const slack = (meta.slack ?? {}) as Record<string, unknown>;

// Canonical wins; flat fields are the legacy de-facto shape.
const discordChannelId =
(typeof discord.channelId === "string" ? discord.channelId : undefined) ??
(typeof meta.discordChannelId === "string"
? meta.discordChannelId
: undefined);
const discordGuildId =
(typeof discord.guildId === "string" ? discord.guildId : undefined) ??
(typeof meta.discordServerId === "string"
? meta.discordServerId
: undefined);
const discordThreadId =
typeof discord.threadId === "string" ? discord.threadId : undefined;

// No `meta.fromId` fallback for Telegram: `fromId` is the sender's user
// id, which equals the chat id only in private 1:1 DMs. In group chats /
// channels the chat id is a distinct (typically negative) integer, so
// falling back to fromId would silently route the workflow to the wrong
// entity. Only use the canonical `metadata.telegram.chatId`. If the
// upstream Telegram plugin hasn't populated it yet, we skip Telegram
// routing rather than guess.
const telegramChatId =
typeof telegram.chatId === "string" || typeof telegram.chatId === "number"
? telegram.chatId
: undefined;
const telegramThreadId =
typeof telegram.threadId === "string" ||
typeof telegram.threadId === "number"
? (telegram.threadId as string | number)
: undefined;

const slackChannelId =
typeof slack.channelId === "string" ? slack.channelId : undefined;
const slackTeamId =
typeof slack.teamId === "string" ? slack.teamId : undefined;

if (discordChannelId) {
return {
source: "discord",
discord: {
...(discordChannelId ? { channelId: discordChannelId } : {}),
...(discordGuildId ? { guildId: discordGuildId } : {}),
...(discordThreadId ? { threadId: discordThreadId } : {}),
},
};
}
if (telegramChatId !== undefined) {
return {
source: "telegram",
telegram: {
chatId: telegramChatId,
...(telegramThreadId !== undefined
? { threadId: telegramThreadId }
: {}),
},
};
}
if (slackChannelId) {
return {
source: "slack",
slack: {
channelId: slackChannelId,
...(slackTeamId ? { teamId: slackTeamId } : {}),
},
};
}
return undefined;
}

function readPosition(value: unknown): [number, number] | null {
return Array.isArray(value) &&
value.length >= 2 &&
Expand Down Expand Up @@ -1130,10 +1270,14 @@ async function handleGenerateWorkflow(ctx: N8nRouteContext): Promise<boolean> {

const name = readOptionalString(body, "name");
const workflowId = readOptionalString(body, "workflowId");
const bridgeConversationId = readOptionalString(body, "bridgeConversationId");

const service = ctx.runtime?.getService?.("n8n_workflow") as
| {
generateWorkflowDraft?: (prompt: string) => Promise<{
generateWorkflowDraft?: (
prompt: string,
opts?: { triggerContext?: TriggerContext },
) => Promise<{
id?: string;
[k: string]: unknown;
}>;
Expand All @@ -1158,7 +1302,14 @@ async function handleGenerateWorkflow(ctx: N8nRouteContext): Promise<boolean> {
return true;
}

const draft = await service.generateWorkflowDraft(prompt);
const triggerContext = bridgeConversationId
? await buildTriggerContextFromConversation(ctx.runtime, bridgeConversationId)
: undefined;

const draft = await service.generateWorkflowDraft(
prompt,
triggerContext ? { triggerContext } : undefined,
);
if (name?.trim()) {
(draft as Record<string, unknown>).name = name.trim();
}
Expand Down
1 change: 1 addition & 0 deletions packages/app-core/src/components/pages/AutomationsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4596,6 +4596,7 @@ function AutomationsLayout() {
prompt,
...(title?.trim() ? { name: title.trim() } : {}),
...(workflowId ? { workflowId } : {}),
...(bridgeConversationId ? { bridgeConversationId } : {}),
});
if (isMissingCredentialsResponse(result)) {
setMissingCredentials(result.missingCredentials);
Expand Down
63 changes: 63 additions & 0 deletions packages/app-core/src/runtime/eliza.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,14 @@ async function repairRuntimeAfterBoot(
// triggers can dispatch immediately on first emit.
await ensureTriggerEventBridge(runtime);

// Register the n8n runtime-context provider so the patched
// `@elizaos/plugin-n8n-workflow` can pull real Discord guild/channel IDs
// and the user's Gmail email into the workflow-generation prompt — closing
// the placeholder + missing-credentials-block gaps. The plugin treats this
// service as advisory; if it isn't registered the prompt simply omits the
// facts/credentials sections.
await ensureN8nRuntimeContextProvider(runtime);

return runtime;
}

Expand All @@ -539,6 +547,11 @@ let _n8nDispatch: { execute: (workflowId: string) => Promise<unknown> } | null =
// event bus.
let _triggerEventBridge: { stop: () => void } | null = null;

// Module-level handle for the n8n runtime-context provider. Reset across
// hot-reloads so the previous closure (capturing an outdated config getter)
// does not survive into the fresh runtime's services map.
let _n8nRuntimeContextProvider: { stop: () => void } | null = null;

async function ensureN8nAuthBridge(runtime: AgentRuntime): Promise<void> {
if (_n8nAuthBridge) {
try {
Expand Down Expand Up @@ -660,6 +673,56 @@ async function ensureTriggerEventBridge(runtime: AgentRuntime): Promise<void> {
}
}

async function ensureN8nRuntimeContextProvider(
runtime: AgentRuntime,
): Promise<void> {
if (_n8nRuntimeContextProvider) {
try {
_n8nRuntimeContextProvider.stop();
} catch {
/* ignore */
}
_n8nRuntimeContextProvider = null;
}
try {
const { startMiladyN8nRuntimeContextProvider } = await import(
"../services/n8n-runtime-context-provider.js"
);
// If a sibling `n8n_credential_provider` is registered (Milady ships one
// separately), reach into the runtime services map for its `resolve` so
// the context provider can filter `supportedCredentials` to types that
// actually have data right now. Optional — without it the context
// provider falls back to "config has connector token" heuristics.
const credEntries =
runtime.services.get("n8n_credential_provider" as never) ?? [];
const credProviderInstance = credEntries[0] as
| {
resolve?: (
userId: string,
credType: string,
) => Promise<unknown>;
}
| undefined;
const credProvider =
credProviderInstance && typeof credProviderInstance.resolve === "function"
? (credProviderInstance as Parameters<
typeof startMiladyN8nRuntimeContextProvider
>[1]["credProvider"])
: undefined;
_n8nRuntimeContextProvider = startMiladyN8nRuntimeContextProvider(runtime, {
getConfig: () => loadElizaConfig(),
credProvider,
});
logger.info("[eliza] n8n runtime-context provider registered");
} catch (err) {
logger.warn(
`[eliza] Failed to register n8n runtime-context provider: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}

// Module-level Telegraf bot reference for lifecycle management across restarts.
let _telegramBot: { stop: (reason?: string) => void } | null = null;

Expand Down
Loading
Loading