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
305 changes: 143 additions & 162 deletions PLAN.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/jinn/src/connectors/cron/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const capabilities: ConnectorCapabilities = {

export class CronConnector implements Connector {
name = "cron";
id = "cron";
private handler: ((msg: IncomingMessage) => void) | null = null;

constructor(
Expand Down
11 changes: 5 additions & 6 deletions packages/jinn/src/connectors/discord/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ export interface DiscordConnectorConfig {
}

export class DiscordConnector implements Connector {
name: string;
instanceId: string;
name = "discord";
id: string;
private client: Client;
private config: DiscordConnectorConfig;
private handler: ((msg: IncomingMessage) => void) | null = null;
Expand All @@ -49,8 +49,7 @@ export class DiscordConnector implements Connector {
private typingIntervals = new Map<string, ReturnType<typeof setInterval>>();

constructor(config: DiscordConnectorConfig) {
this.name = config.id || "discord";
this.instanceId = config.id || "discord";
this.id = config.id || "discord";
this.config = config;
// Normalize Discord IDs to strings (YAML may parse large snowflake IDs as numbers)
if (this.config.guildId) this.config.guildId = String(this.config.guildId);
Expand Down Expand Up @@ -271,7 +270,7 @@ export class DiscordConnector implements Connector {

if (!this.handler) return;

const sessionKey = deriveSessionKey(message, this.instanceId);
const sessionKey = deriveSessionKey(message, this.id);
const replyContext = buildReplyContext(message);

// Download attachments
Expand All @@ -287,7 +286,7 @@ export class DiscordConnector implements Connector {
).then((results) => results.filter(Boolean) as Array<{ name: string; localPath: string; mimeType: string }>);

const incomingMessage: IncomingMessage = {
connector: this.instanceId,
connector: this.id,
source: "discord",
sessionKey,
channel: message.channel.id,
Expand Down
1 change: 1 addition & 0 deletions packages/jinn/src/connectors/discord/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface RemoteDiscordConfig {
*/
export class RemoteDiscordConnector implements Connector {
name = "discord";
id = "discord";
private handler: ((msg: IncomingMessage) => void) | null = null;
private baseUrl: string;

Expand Down
14 changes: 8 additions & 6 deletions packages/jinn/src/connectors/slack/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { logger } from "../../shared/logger.js";

export class SlackConnector implements Connector {
name = "slack";
id: string;
private app: App;
private handler: ((msg: IncomingMessage) => void) | null = null;
private readonly allowedUsers: Set<string> | null;
Expand Down Expand Up @@ -58,6 +59,7 @@ export class SlackConnector implements Connector {
}

constructor(config: SlackConnectorConfig) {
this.id = config.id || "slack";
this.app = new App({
token: config.botToken,
appToken: config.appToken,
Expand Down Expand Up @@ -129,7 +131,7 @@ export class SlackConnector implements Connector {
return;
}

const sessionKey = deriveSessionKey(event as any);
const sessionKey = deriveSessionKey(event as any, this.id);
const replyContext = buildReplyContext(event as any);

// Fetch parent message for thread replies so the session has full context
Expand Down Expand Up @@ -177,7 +179,7 @@ export class SlackConnector implements Connector {
const channelName = await this.resolveChannelName((event as any).channel);

const msg: IncomingMessage = {
connector: this.name,
connector: this.id,
source: "slack",
sessionKey,
replyContext,
Expand Down Expand Up @@ -228,7 +230,7 @@ export class SlackConnector implements Connector {
return;
}

const sessionKey = deriveSessionKey(event as any);
const sessionKey = deriveSessionKey(event as any, this.id);
const replyContext = buildReplyContext(event as any);
const channelName = await this.resolveChannelName(event.channel);

Expand All @@ -255,7 +257,7 @@ export class SlackConnector implements Connector {
}

const msg: IncomingMessage = {
connector: this.name,
connector: this.id,
source: "slack",
sessionKey,
replyContext,
Expand Down Expand Up @@ -368,10 +370,10 @@ export class SlackConnector implements Connector {
// Build the prompt with reaction context
const prompt = `[Reaction :${emoji}: on message in ${channelDisplay}]\n\nOriginal message:\n"${messageText}"\n\nThe user reacted with :${emoji}: to this message. Interpret and act on the reaction.`;

const sessionKey = `slack:reaction:${channelId}:${messageTs}`;
const sessionKey = `${this.id}:reaction:${channelId}:${messageTs}`;

const msg: IncomingMessage = {
connector: this.name,
connector: this.id,
source: "slack",
sessionKey,
replyContext: {
Expand Down
7 changes: 7 additions & 0 deletions packages/jinn/src/connectors/slack/threads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ test("deriveSessionKey treats same-ts thread_ts as root message", () => {
expect(key).toBe("slack:C123:1700000000.000100");
});

test("deriveSessionKey honours a custom instance prefix", () => {
const dm = { channel: "D123", user: "U123", channel_type: "im", ts: "1700000000.000100" };
expect(deriveSessionKey(dm, "slack-support")).toBe("slack-support:dm:U123");
const channel = { channel: "C123", user: "U123", ts: "1700000000.000100" };
expect(deriveSessionKey(channel, "slack-support")).toBe("slack-support:C123:1700000000.000100");
});

test("buildReplyContext sets thread for channel root messages", () => {
const context = buildReplyContext({
channel: "C123",
Expand Down
17 changes: 5 additions & 12 deletions packages/jinn/src/connectors/slack/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,11 @@ export interface SlackMessageEventLike {
channel_type?: string;
}

export function deriveSessionKey(event: SlackMessageEventLike): string {
if (event.channel_type === "im") {
return `slack:dm:${event.user || "unknown"}`;
}

// Thread reply — use thread_ts (which is the root message's ts)
if (event.thread_ts && event.thread_ts !== event.ts) {
return `slack:${event.channel}:${event.thread_ts}`;
}

// Root channel message — use ts so thread replies will match
return `slack:${event.channel}:${event.ts}`;
export function deriveSessionKey(event: SlackMessageEventLike, prefix = "slack"): string {
if (event.channel_type === "im") return `${prefix}:dm:${event.user || "unknown"}`;
// Thread replies key off thread_ts (the root's ts), so they land on the root's session.
const ts = event.thread_ts && event.thread_ts !== event.ts ? event.thread_ts : event.ts;
return `${prefix}:${event.channel}:${ts}`;
}

export function buildReplyContext(event: SlackMessageEventLike): ReplyContext {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { IncomingMessage, Target } from "../../../shared/types.js";
import type { IncomingMessage, Session, Target } from "../../../shared/types.js";

// Mock node-telegram-bot-api before importing connector
const mockSendMessage = vi.fn().mockResolvedValue({ message_id: 1 });
Expand Down Expand Up @@ -32,6 +32,7 @@ vi.mock("../../../shared/logger.js", () => ({

// Import after mocks are set up
const { TelegramConnector } = await import("../index.js");
const { deliverConnectorReply } = await import("../../../gateway/api.js");

describe("TelegramConnector", () => {
let connector: InstanceType<typeof TelegramConnector>;
Expand Down Expand Up @@ -97,6 +98,43 @@ describe("TelegramConnector", () => {
});

describe("onMessage", () => {
it("stamps and replies through a named connector instance id", async () => {
const named = new TelegramConnector({
id: "telegram-support",
botToken: "123456:ABC-DEF",
});
const handler = vi.fn();
named.onMessage(handler);
await named.start();

const messageCallback = mockOn.mock.calls.find(
(call) => call[0] === "message",
)?.[1];
await messageCallback({
message_id: 42,
chat: { id: 12345, type: "private" as const },
from: { id: 67890, username: "testuser", first_name: "Test", is_bot: false },
date: Math.floor(Date.now() / 1000) + 10,
text: "Hello named bot!",
});

const incoming: IncomingMessage = handler.mock.calls[0][0];
expect(incoming.connector).toBe("telegram-support");
expect(incoming.sessionKey).toBe("telegram-support:12345");

await deliverConnectorReply({
id: "session-named",
source: incoming.source,
connector: incoming.connector,
replyContext: incoming.replyContext,
} as Session, "Named reply", new Map([[named.id, named]]));

expect(mockSendMessage).toHaveBeenCalledWith("12345", "Named reply", {
parse_mode: "Markdown",
reply_parameters: { message_id: 42 },
});
});

it("routes incoming messages to the handler", async () => {
const handler = vi.fn();
connector.onMessage(handler);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ describe("deriveSessionKey", () => {
deriveSessionKey({ chat: { id: -1001234, type: "supergroup" }, message_id: 1 }),
).toBe("telegram:-1001234");
});

it("honours a custom instance prefix", () => {
expect(
deriveSessionKey({ chat: { id: 12345, type: "private" }, message_id: 1 }, "telegram-support"),
).toBe("telegram-support:12345");
});
});

describe("buildReplyContext", () => {
Expand Down
6 changes: 4 additions & 2 deletions packages/jinn/src/connectors/telegram/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type SendMessageOptions = Omit<SendMessageParams, "chat_id" | "text">;

export class TelegramConnector implements Connector {
name = "telegram";
id: string;
private bot: TelegramBot;
private handler: ((msg: IncomingMessage) => void) | null = null;
private readonly allowedUsers: Set<number> | null;
Expand All @@ -48,6 +49,7 @@ export class TelegramConnector implements Connector {
private sttPending = 0;

constructor(config: TelegramConnectorConfig) {
this.id = config.id || "telegram";
this.bot = new TelegramBot(config.botToken, { polling: false });
this.ignoreOldMessagesOnBoot = config.ignoreOldMessagesOnBoot !== false;
this.allowedUsers =
Expand Down Expand Up @@ -101,7 +103,7 @@ export class TelegramConnector implements Connector {
}
}

const sessionKey = deriveSessionKey(telegramMsg);
const sessionKey = deriveSessionKey(telegramMsg, this.id);
const replyContext = buildReplyContext(telegramMsg);

const username =
Expand Down Expand Up @@ -302,7 +304,7 @@ export class TelegramConnector implements Connector {
}

const msg: IncomingMessage = {
connector: this.name,
connector: this.id,
source: "telegram",
sessionKey,
replyContext,
Expand Down
9 changes: 3 additions & 6 deletions packages/jinn/src/connectors/telegram/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,9 @@ export interface TelegramMessageLike {
date?: number;
}

/**
* Derive a session key from a Telegram message.
* Format: telegram:<chatId>
*/
export function deriveSessionKey(msg: TelegramMessageLike): string {
return `telegram:${msg.chat.id}`;
/** Derive a session key from a Telegram message. Format: `<prefix>:<chatId>`. */
export function deriveSessionKey(msg: TelegramMessageLike, prefix = "telegram"): string {
return `${prefix}:${msg.chat.id}`;
}

/**
Expand Down
69 changes: 69 additions & 0 deletions packages/jinn/src/connectors/whatsapp/__tests__/connector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";

const testHome = fs.mkdtempSync(path.join(os.tmpdir(), "jinn-whatsapp-identity-"));
process.env.JINN_HOME = testHome;

const mocks = vi.hoisted(() => {
const eventOn = vi.fn();
const useMultiFileAuthState = vi.fn(async (_authDir: string) => ({ state: {}, saveCreds: vi.fn() }));
const socket = {
ev: { on: eventOn },
end: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn(),
sendPresenceUpdate: vi.fn().mockResolvedValue(undefined),
};
return {
eventOn,
useMultiFileAuthState,
makeWASocket: vi.fn(() => socket),
};
});

vi.mock("@whiskeysockets/baileys", () => ({
default: mocks.makeWASocket,
Browsers: { macOS: vi.fn(() => ["macOS", "Chrome", "test"]) },
DisconnectReason: { loggedOut: 401 },
fetchLatestWaWebVersion: vi.fn().mockResolvedValue({ version: undefined }),
useMultiFileAuthState: mocks.useMultiFileAuthState,
downloadMediaMessage: vi.fn(),
}));

vi.mock("../../../shared/logger.js", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
},
}));

const { WhatsAppConnector } = await import("../index.js");

describe("WhatsAppConnector identity", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("preserves legacy auth storage and isolates named default auth state", async () => {
const legacy = new WhatsAppConnector({});
const support = new WhatsAppConnector({ id: "whatsapp-support" });
const operations = new WhatsAppConnector({ id: "whatsapp-operations" });

await legacy.start();
await support.start();
await operations.start();

const authRoot = path.join(testHome, ".whatsapp-auth");
expect(mocks.useMultiFileAuthState.mock.calls.map(([authDir]) => authDir)).toEqual([
authRoot,
path.join(authRoot, "whatsapp-support"),
path.join(authRoot, "whatsapp-operations"),
]);
expect(fs.existsSync(authRoot)).toBe(true);
expect(fs.existsSync(path.join(authRoot, "whatsapp-support"))).toBe(true);
expect(fs.existsSync(path.join(authRoot, "whatsapp-operations"))).toBe(true);
});
});
11 changes: 8 additions & 3 deletions packages/jinn/src/connectors/whatsapp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import path from "node:path";
import fs from "node:fs";

export interface WhatsAppConnectorConfig {
/** Unique instance identifier (e.g. "whatsapp-main") */
id?: string;
/** Where to store session credentials (default: JINN_HOME/.whatsapp-auth) */
authDir?: string;
/** Allowed phone numbers in JID format (e.g. "447700900000@s.whatsapp.net") — empty = allow all */
Expand All @@ -44,6 +46,7 @@ const silentLogger = {

export class WhatsAppConnector implements Connector {
name = "whatsapp";
id: string;
private sock: WASocket | null = null;
private config: WhatsAppConnectorConfig;
private handler: ((msg: IncomingMessage) => void) | null = null;
Expand All @@ -65,8 +68,10 @@ export class WhatsAppConnector implements Connector {
};

constructor(config: WhatsAppConnectorConfig) {
this.id = config.id || "whatsapp";
this.config = config;
this.authDir = config.authDir ?? path.join(JINN_HOME, ".whatsapp-auth");
const defaultAuthDir = path.join(JINN_HOME, ".whatsapp-auth");
this.authDir = config.authDir ?? (this.id === "whatsapp" ? defaultAuthDir : path.join(defaultAuthDir, this.id));
this.allowedJids = new Set(config.allowFrom ?? []);
fs.mkdirSync(this.authDir, { recursive: true });
}
Expand Down Expand Up @@ -305,11 +310,11 @@ export class WhatsAppConnector implements Connector {
}
}

const sessionKey = `whatsapp:${jid}`;
const sessionKey = `${this.id}:${jid}`;
const replyContext = { channel: jid, thread: null, messageTs: message.key.id ?? null };

const incomingMessage: IncomingMessage = {
connector: "whatsapp",
connector: this.id,
source: "whatsapp",
sessionKey,
channel: jid,
Expand Down
Loading
Loading