enabled whatsapp interface

This commit is contained in:
Raj Sharma 2025-01-12 20:05:43 +05:30
parent 4a01429e4a
commit cdc3fcab74
15 changed files with 1447 additions and 597 deletions

View File

@ -61,13 +61,28 @@ const ConfigSchema = z.object({
rolePermissions: z.record(z.string(), z.array(z.string())),
});
// Load user configuration data from file
// Mutable exports that will be updated
export let userConfigs: UserConfig[] = [];
export let rolePermissions: Record<string, string[]> = {};
// Function to load config
function loadConfig() {
try {
const userConfigPath = pathInDataDir("user-config.json");
const rawData = fs.readFileSync(userConfigPath, "utf-8");
const parsedData = JSON.parse(rawData);
// Validate the parsed JSON using the Zod schema
const configData = ConfigSchema.parse(parsedData);
// Export the validated data
export const { users: userConfigs, rolePermissions } = configData;
// Update the exported variables
userConfigs = configData.users;
rolePermissions = configData.rolePermissions;
} catch (error) {
console.error("Error loading config:", error);
}
}
// Initial load
loadConfig();
// Setup auto-reload every minute
setInterval(loadConfig, 60 * 1000);

View File

@ -32,15 +32,16 @@ export class MessageProcessor {
});
}
private checkpointMessageString = "🔄 Chat context has been reset.";
public async processMessage(message: Message): Promise<void> {
const userId = message.author.id;
const channelId = message.channelId || userId; // Use message.id if channelId is not available
// Check if the message is a stop message
if (["stop", "reset"].includes(message.content.toLowerCase())) {
message.platform !== "whatsapp" &&
(await message.send({
content: "---setting this point as the start---",
content: this.checkpointMessageString,
}));
// Clear maps
const hashes = this.channelIdHashMap.get(channelId) ?? [];
@ -98,8 +99,7 @@ export class MessageProcessor {
let stopIndex = -1;
for (let i = 0; i < history.length; i++) {
if (
history[i].content === "---setting this point as the start---" ||
history[i].content.replaceAll("!", "").trim() === "stop"
history[i].content === this.checkpointMessageString
) {
stopIndex = i;
break;
@ -179,6 +179,10 @@ export class MessageProcessor {
.map((e) => JSON.stringify(e))
.join("\n");
console.log("Embeds", embeds?.length);
console.log("Files", files?.length);
console.log("Attachments", msg?.attachments?.length);
// Transcribe voice messages
const voiceMessagesPromises = (msg.attachments || [])
.filter(
@ -197,6 +201,11 @@ export class MessageProcessor {
const voiceMessages = await Promise.all(voiceMessagesPromises);
console.log("Voice Messages", voiceMessages);
const images = (msg.attachments || [])
.filter((a) => a.mediaType?.includes("image"))
// Process context message if any
let contextMessage = null;
if (msg.threadId) {
@ -238,7 +247,20 @@ export class MessageProcessor {
const aiMessage: OpenAI.Chat.ChatCompletionMessageParam = {
role,
content: contextAsJson,
content: (images.length ? [
...images.map(img => {
return {
type: "image_url",
image_url: {
url: img.base64 || img.url,
},
}
}),
{
type: "text",
text: contextAsJson,
}
] : contextAsJson) as string,
name:
user?.name ||
msg.author.username.replace(/\s+/g, "_").substring(0, 64),
@ -260,7 +282,7 @@ export class MessageProcessor {
// Collect hashes
history.forEach((msg) => {
const hash = this.generateHash(msg.content);
const hash = this.generateHash(typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content));
channelHashes.push(hash);
});
@ -376,9 +398,13 @@ ${summaryContent}
queueEntry.runningTools = true;
}
// Indicate running tools
if (this.sentMessage?.platformAdapter.config.indicators.processing) {
if (this.sentMessage) {
await this.sentMessage.edit({ content: `Running ${fnc.name}...` });
} else await message.send({ content: `Running ${fnc.name}...` });
} else {
this.sentMessage = await message.send({ content: `Running ${fnc.name}...` })
}
}
})
.on("message", (m) => {
if (
@ -434,7 +460,7 @@ ${summaryContent}
private generateHash(input: string): string {
const hash = createHash("sha256");
hash.update(input);
hash.update(typeof input === "string" ? input : JSON.stringify(input));
return hash.digest("hex");
}
}

View File

@ -18,6 +18,7 @@ import {
DMChannel,
} from "discord.js";
import { UserConfig, userConfigs } from "../config";
import { get_transcription } from "../tools/ask"; // Add this import
export class DiscordAdapter implements PlatformAdapter {
private client: Client;
@ -254,6 +255,33 @@ export class DiscordAdapter implements PlatformAdapter {
// Expose getMessageInterface method
public getMessageInterface = this.createMessageInterface;
public async handleMediaAttachment(attachment: Attachment) {
if (!attachment.url) return { mediaType: 'other' as const };
const response = await fetch(attachment.url);
const buffer = await response.arrayBuffer();
if (attachment.contentType?.includes('image')) {
const base64 = `data:${attachment.contentType};base64,${Buffer.from(buffer).toString('base64')}`;
return {
base64,
mediaType: 'image' as const
};
}
if (attachment.contentType?.includes('audio')) {
// Create temporary file for transcription
const tempFile = new File([buffer], 'audio', { type: attachment.contentType });
const transcription = await get_transcription(tempFile);
return {
transcription,
mediaType: 'audio' as const
};
}
return { mediaType: 'other' as const };
}
private async convertMessage(
discordMessage: DiscordMessage
): Promise<StdMessage> {
@ -263,10 +291,19 @@ export class DiscordAdapter implements PlatformAdapter {
config: this.getUserById(discordMessage.author.id),
};
const attachments: Attachment[] = discordMessage.attachments.map(
(attachment) => ({
const attachments: Attachment[] = await Promise.all(
discordMessage.attachments.map(async (attachment) => {
const stdAttachment: Attachment = {
url: attachment.url,
contentType: attachment.contentType || undefined,
};
const processedMedia = await this.handleMediaAttachment(stdAttachment);
if (processedMedia.base64) stdAttachment.base64 = processedMedia.base64;
if (processedMedia.transcription) stdAttachment.transcription = processedMedia.transcription;
stdAttachment.mediaType = processedMedia.mediaType;
return stdAttachment;
})
);

View File

@ -4,6 +4,18 @@ import { startEventsServer } from "./events";
import { Message } from "./message";
import { WhatsAppAdapter } from "./whatsapp";
// Add debounce utility function
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait) as any;
};
}
// Initialize Discord Adapter and Processor
export const discordAdapter = new DiscordAdapter();
@ -17,9 +29,13 @@ export function startInterfaces() {
discordAdapter.onMessage(async (message) => {
await discordProcessor.processMessage(message);
});
whatsappAdapter.onMessage(async (message) => {
// Debounce WhatsApp messages with 500ms delay
const debouncedWhatsAppProcessor = debounce(async (message) => {
await whatsappProcessor.processMessage(message);
});
}, 1000);
whatsappAdapter.onMessage(debouncedWhatsAppProcessor);
startEventsServer();
}

View File

@ -19,6 +19,9 @@ export interface Attachment {
contentType?: string;
data?: Buffer | string;
type?: string;
mediaType?: 'image' | 'audio' | 'other';
base64?: string;
transcription?: string;
}
export interface Embed {

View File

@ -1,5 +1,5 @@
import { UserConfig } from "../config";
import { Message, User } from "./message";
import { Attachment, Message, User } from "./message";
export interface FetchOptions {
limit?: number;
@ -25,4 +25,9 @@ export interface PlatformAdapter {
processing: boolean;
};
};
handleMediaAttachment?(attachment: Attachment): Promise<{
base64?: string;
transcription?: string;
mediaType: 'image' | 'audio' | 'other';
}>;
}

View File

@ -12,20 +12,17 @@ import {
MessageMedia,
} from "whatsapp-web.js";
import { UserConfig, userConfigs } from "../config";
import { eventManager } from "./events";
// import { eventManager } from "./events";
import { return_current_listeners } from "../tools/events";
import Fuse from "fuse.js";
// const allowedUsers = ["pooja", "raj"];
const allowedUsers: string[] = [];
import { get_transcription } from "../tools/ask"; // Add this import
export class WhatsAppAdapter implements PlatformAdapter {
private client: WAClient;
private botUserId: string = "918884016724@c.us";
public config = {
indicators: {
typing: false,
typing: true,
processing: false,
},
};
@ -39,6 +36,11 @@ export class WhatsAppAdapter implements PlatformAdapter {
console.log("WhatsApp Client is ready!");
});
this.client.on("qr", (qr) => {
console.log("QR Code received. Please scan with WhatsApp:");
console.log(qr);
});
this.client.initialize();
} catch (error) {
console.log(`Failed to initialize WhatsApp client: `, error);
@ -62,6 +64,8 @@ export class WhatsAppAdapter implements PlatformAdapter {
public onMessage(callback: (message: StdMessage) => void): void {
this.client.on("message_create", async (waMessage: WAMessage) => {
// emit internal event only if text message and there is an active listener
const listeners = return_current_listeners();
if (
@ -69,16 +73,16 @@ export class WhatsAppAdapter implements PlatformAdapter {
!waMessage.fromMe &&
listeners.find((l) => l.eventId.includes("whatsapp"))
) {
const contact = await this.client.getContactById(waMessage.from);
eventManager.emit("got_whatsapp_message", {
sender_id: waMessage.from,
sender_contact_name:
contact.name || contact.shortName || contact.pushname || "NA",
timestamp: waMessage.timestamp,
content: waMessage.body,
profile_image_url: await contact.getProfilePicUrl(),
is_group_message: contact.isGroup.toString(),
});
// const contact = await this.client.getContactById(waMessage.from);
// eventManager.emit("got_whatsapp_message", {
// sender_id: waMessage.from,
// sender_contact_name:
// contact.name || contact.shortName || contact.pushname || "NA",
// timestamp: waMessage.timestamp,
// content: waMessage.body,
// profile_image_url: await contact.getProfilePicUrl(),
// is_group_message: contact.isGroup.toString(),
// });
}
// user must exist in userConfigs
@ -88,14 +92,15 @@ export class WhatsAppAdapter implements PlatformAdapter {
return;
}
// user must be in allowedUsers
if (!allowedUsers.includes(usr.name)) {
// console.log(`User not allowed: ${usr.name}`);
return;
}
// // user must be in allowedUsers
// if (!allowedUsers.includes(usr.name)) {
// console.log(`User not allowed: ${usr.name}`, allowedUsers);
// return;
// }
// Ignore messages sent by the bot
if (waMessage.fromMe) return;
const message = await this.convertMessage(waMessage);
callback(message);
@ -125,7 +130,7 @@ export class WhatsAppAdapter implements PlatformAdapter {
}
public getBotId(): string {
return this.botUserId;
return this.client.info.wid.user;
}
public async createMessageInterface(userId: string): Promise<StdMessage> {
@ -280,6 +285,32 @@ export class WhatsAppAdapter implements PlatformAdapter {
// Expose this method so it can be accessed elsewhere
public getMessageInterface = this.createMessageInterface;
public async handleMediaAttachment(attachment: Attachment) {
if (!attachment.data) return { mediaType: 'other' as const };
const buffer = Buffer.from(attachment.data as string, 'base64');
if (attachment.type?.includes('image')) {
const base64 = `data:${attachment.contentType};base64,${buffer.toString('base64')}`;
return {
base64,
mediaType: 'image' as const
};
}
if (attachment.type?.includes('audio')) {
// Create temporary file for transcription
const tempFile = new File([buffer], 'audio', { type: attachment.contentType });
const transcription = await get_transcription(tempFile);
return {
transcription,
mediaType: 'audio' as const
};
}
return { mediaType: 'other' as const };
}
private async convertMessage(waMessage: WAMessage): Promise<StdMessage> {
const contact = await waMessage.getContact();
@ -292,14 +323,25 @@ export class WhatsAppAdapter implements PlatformAdapter {
// Convert attachments
let attachments: Attachment[] = [];
if (waMessage.hasMedia) {
console.log("Downloading media...");
const media = await waMessage.downloadMedia();
attachments.push({
url: "", // WhatsApp does not provide a direct URL to the media
const attachment: Attachment = {
url: "",
data: media.data,
contentType: media.mimetype,
type: waMessage.type,
});
type: waMessage.type
};
console.log("Processing media attachment...");
const processedMedia = await this.handleMediaAttachment(attachment);
console.log("Processed media attachment:", processedMedia.transcription);
if (processedMedia.base64) attachment.base64 = processedMedia.base64;
if (processedMedia.transcription) attachment.transcription = processedMedia.transcription;
attachment.mediaType = processedMedia.mediaType;
attachments.push(attachment);
}
const stdMessage: StdMessage = {
@ -358,7 +400,7 @@ export class WhatsAppAdapter implements PlatformAdapter {
user.identities.some(
(identity) =>
identity.platform === "whatsapp" &&
identity.id === contact.id._serialized
identity.id === contact.id.user
)
);
return userConfig ? userConfig.roles : ["user"];
@ -400,7 +442,11 @@ export class WhatsAppAdapter implements PlatformAdapter {
sendTyping: async () => {
// WhatsApp Web API does not support sending typing indicators directly
// You may leave this as a no-op
const chat = await this.client.getChatById(waMessage.from)
await chat.sendStateTyping()
},
attachments,
};
return stdMessage;
@ -540,6 +586,8 @@ export class WhatsAppAdapter implements PlatformAdapter {
sendTyping: async () => {
// WhatsApp Web API does not support sending typing indicators directly
// You may leave this as a no-op
const chat = await this.client.getChatById(sentWAMessage.from)
await chat.sendStateTyping()
},
};
}

View File

@ -19,6 +19,9 @@ import { memory_manager_guide, memory_manager_init } from "./memory-manager";
// Paths to the JSON files
const ACTIONS_FILE_PATH = pathInDataDir("actions.json");
// Add this constant at the top with other constants
const MIN_SCHEDULE_INTERVAL_SECONDS = 600; // 10 minutes in seconds
// Define schema for creating an action
export const CreateActionParams = z.object({
actionId: z
@ -347,6 +350,13 @@ export async function create_action(
let { actionId, description, schedule, instruction, tool_names } =
parsed.data;
// Validate schedule frequency
if (!validateScheduleFrequency(schedule)) {
return {
error: "❌ Schedule frequency cannot be less than 10 minutes. Please adjust the schedule."
};
}
// Get the userId from contextMessage
const userId: string = contextMessage.author.id;
@ -484,6 +494,13 @@ export async function update_action(
notify,
} = parsed.data;
// Validate schedule frequency
if (!validateScheduleFrequency(schedule)) {
return {
error: "❌ Schedule frequency cannot be less than 10 minutes. Please adjust the schedule."
};
}
// Get the userId from contextMessage
const userId: string = contextMessage.author.id;
@ -534,16 +551,25 @@ const action_tools: (
schema: CreateActionParams,
description: `Creates a new action.
**IMPORTANT SCHEDULING LIMITATION:**
Actions CANNOT be scheduled more frequently than once every 10 minutes. This is a hard system limitation that cannot be overridden.
- For delays: Minimum delay is 600 seconds (10 minutes)
- For cron: Must have at least 10 minutes between executions
**Example:**
- **User:** "Send a summary email in 10 minutes"
- **User:** "Send a summary email every 10 minutes"
- **Action ID:** "send_summary_email"
- **Description:** "Sends a summary email after a delay."
- **Schedule:** { type: "delay", time: 600 }
- **Description:** "Sends a summary email periodically"
- **Schedule:** { type: "cron", time: "*/10 * * * *" }
- **Instruction:** "Compose and send a summary email to the user."
- **Required Tools:** ["email_service"]
**Notes:**
- Supported scheduling types: 'delay' (in seconds), 'cron' (cron expressions).
**Invalid Examples:**
Every 5 minutes: "*/5 * * * *"
Delay of 300 seconds
Multiple times within 10 minutes
The system will automatically reject any schedule that attempts to run more frequently than every 10 minutes.
`,
}),
// zodFunction({
@ -710,5 +736,41 @@ Use the data provided above to fulfill the user's request.
}
}
// Replace the existing validateCronFrequency function with this more comprehensive one
function validateScheduleFrequency(schedule: { type: "delay" | "cron"; time: number | string }): boolean {
try {
if (schedule.type === "delay") {
const delaySeconds = schedule.time as number;
return delaySeconds >= MIN_SCHEDULE_INTERVAL_SECONDS;
} else if (schedule.type === "cron") {
const cronExpression = schedule.time as string;
const intervals = cronExpression.split(' ');
// Check minutes field (first position)
const minutesPart = intervals[0];
if (minutesPart === '*') return false;
if (minutesPart.includes('/')) {
const step = parseInt(minutesPart.split('/')[1]);
if (step < 10) return false;
}
// Convert specific minute values to ensure they're at least 10 minutes apart
if (!minutesPart.includes('/')) {
const minutes = minutesPart.split(',').map(Number);
if (minutes.length > 1) {
minutes.sort((a, b) => a - b);
for (let i = 1; i < minutes.length; i++) {
if (minutes[i] - minutes[i - 1] < 10) return false;
}
if ((60 - minutes[minutes.length - 1] + minutes[0]) < 10) return false;
}
}
return true;
}
return false;
} catch {
return false;
}
}
// Initialize by loading actions from file when the module is loaded
loadActionsFromFile();

View File

@ -133,12 +133,12 @@ export async function ask({
tools,
seed,
json,
image_url,
image_urls, // Changed from image_url to image_urls array
}: {
model?: string;
prompt: string;
message?: string;
image_url?: string;
image_urls?: string[]; // Changed to array of strings
name?: string;
tools?: RunnableToolFunctionWithParse<any>[];
seed?: string;
@ -166,6 +166,22 @@ export async function ask({
// Retrieve existing message history
const history = getMessageHistory(seed);
let messageContent: any = message;
if (image_urls && image_urls.length > 0) {
messageContent = [
{
type: "text",
text: message,
},
...image_urls.map((url) => ({
type: "image_url",
image_url: {
url: url,
},
})),
];
}
// Combine system prompt with message history and new user message
messages = [
{
@ -175,24 +191,11 @@ export async function ask({
...history,
{
role: "user",
content: image_url
? [
{
type: "text",
text: message,
},
{
type: "image_url",
image_url: {
url: image_url,
},
},
]
: message,
content: messageContent,
name,
},
];
image_url && console.log("got image:", image_url?.slice(0, 20));
image_urls && console.log("got images:", image_urls.length);
} else if (seed && !message) {
// If seed is provided but no new message, just retrieve history
const history = getMessageHistory(seed);
@ -205,22 +208,25 @@ export async function ask({
];
} else if (!seed && message) {
// If no seed but message is provided, send system prompt and user message without history
messages.push({
role: "user",
content: image_url
? [
let messageContent: any = message;
if (image_urls && image_urls.length > 0) {
messageContent = [
{
type: "text",
text: message,
},
{
...image_urls.map((url) => ({
type: "image_url",
image_url: {
url: image_url,
url: url,
},
},
]
: message,
})),
];
}
messages.push({
role: "user",
content: messageContent,
name,
});
}

View File

@ -17,13 +17,13 @@ const CommunicationManagerSchema = z.object({
.describe(
"The platform you prefer to use, you can leave this empty to default to the current user's platform."
),
prefered_recipient_details: z
.object({
name: z.string().optional(),
user_id: z.string().optional(),
})
.optional()
.describe("Give these details only if you have them."),
// prefered_recipient_details: z
// .object({
// name: z.string().optional(),
// user_id: z.string().optional(),
// })
// .optional()
// .describe("Give these details only if you have them."),
});
export type CommunicationManager = z.infer<typeof CommunicationManagerSchema>;
@ -70,7 +70,7 @@ export async function communication_manager(
{
request,
prefered_platform,
prefered_recipient_details,
// prefered_recipient_details,
}: CommunicationManager,
context_message: Message
) {
@ -78,27 +78,61 @@ export async function communication_manager(
memory_manager_init(context_message, "communications_manager")
);
const prompt = `You are a Communication Manager Tool.
const prompt = `You are a Communication Manager Tool responsible for routing messages to the correct recipients.
Your task is to route messages to the correct recipient.
CONTEXT INFORMATION:
1. Current User (Sender): ${context_message.author.config?.name}
2. Current Platform: ${context_message.platform}
3. WhatsApp Access: ${context_message.getUserRoles().includes("creator")}
4. Available Platforms: discord, whatsapp, email
---
STEP-BY-STEP PROCESS:
1. First, identify the recipient(s) from the request
2. Then, check if recipient exists in this list of known users:
${JSON.stringify(userConfigs, null, 2)}
3. If recipient not found in above list:
- Use search_user tool to find them
- Wait for search results before proceeding
4. Platform Selection:
- If prefered_platform is specified, use that
- If not specified, use current platform: ${context_message.platform}
- For WhatsApp, verify you have creator access first
TOOLS AVAILABLE:
- search_user: Find user details by name
- send_message_to: Send message on discord/whatsapp
- send_email: Send emails (requires verified email address)
- memory_manager: Store user preferences and contact names
${memory_manager_guide("communications_manager", context_message.author.id)}
You can use the \`memory_manager\` tool to remember user preferences, such as what the user calls certain contacts, to help you route messages better.
MESSAGE DELIVERY GUIDELINES:
Act as a professional assistant delivering messages between people. Consider:
---
1. Relationship Context:
- Professional for workplace communications
- Casual for friends and family
- Respectful for all contexts
**Default Platform (if not mentioned):** ${context_message.platform}
2. Message Delivery Style:
- Frame the message naturally as an assistant would when passing along information
- Maintain the original intent and tone of the sender
- Add appropriate context without changing the core message
**Configuration of All Users:** ${JSON.stringify(userConfigs)}
3. Natural Communication:
- Deliver messages as if you're the assistant of the user: ${context_message.author.config?.name}.
- Adapt your tone based on the message urgency and importance
- Include relevant context when delivering reminders or requests
- Keep the human element in the communication
**Can Access 'WhatsApp':** ${context_message.getUserRoles().includes("creator")}
Remember: You're not just forwarding messages, you're acting as a professional assistant helping facilitate communication between people. Make your delivery natural and appropriate for each situation.
**Guidelines:**
- If the user does not mention a platform, use the same platform as the current user.
ERROR PREVENTION:
- Don't halucinate or invent contact details
- Always verify platform availability before sending
- If unsure about recipient, ask for clarification
`;
const response = await ask({
@ -106,9 +140,7 @@ You can use the \`memory_manager\` tool to remember user preferences, such as wh
model: "gpt-4o-mini",
message: `request: ${request}
prefered_platform: ${prefered_platform}
prefered_recipient_details: ${JSON.stringify(prefered_recipient_details)}`,
prefered_platform: ${prefered_platform}`,
tools,
});
@ -128,13 +160,11 @@ export const communication_manager_tool = (context_message: Message) =>
function: (args) => communication_manager(args, context_message),
name: "communication_manager",
schema: CommunicationManagerSchema,
description: `Communications Manager.
description: `Sends messages to one or more recipients across different platforms (discord, whatsapp, email).
This tool routes messages to the specified user on the appropriate platform.
Input format:
request: "send [message] to [recipient(s)]"
prefered_platform: (optional) platform name
Use it to send messages to users on various platforms.
Provide detailed information to ensure the message reaches the correct recipient.
Include in your request the full message content and its context along with the recipient's details.`,
The tool handles recipient lookup, message composition, and delivery automatically.`,
});

View File

@ -13,10 +13,24 @@ export interface PromptAugmentationResult {
updatedSystemPrompt?: string;
message?: string;
updatedTools?: RunnableToolFunctionWithParse<any>[];
attachedImageBase64?: string;
attachedImagesBase64?: string[]; // Changed to string array
model: string;
}
/**
* Helper function to handle transcription of a single file or array of files
*/
async function handleTranscription(
input: File | File[]
): Promise<string | string[]> {
if (Array.isArray(input)) {
return Promise.all(
input.map((file) => get_transcription(file as globalThis.File))
);
}
return get_transcription(input as globalThis.File);
}
/**
* 1) Voice Event Augmentation
* - Possibly do transcription if an audio File is present.
@ -28,27 +42,103 @@ async function voiceEventAugmentation(
baseTools: RunnableToolFunctionWithParse<any>[] | undefined,
contextMessage: Message
): Promise<PromptAugmentationResult> {
let attachedImageBase64: string | undefined;
let attachedImagesBase64: string[] = []; // Changed to array
// Transcribe if there's an audio file
if (payload?.transcription && payload.transcription instanceof File) {
console.log("Transcribing audio for voice event listener.");
const file = payload.transcription;
payload.transcription = await get_transcription(file as globalThis.File);
// Handle transcription - single file or array
if (payload?.transcription) {
if (
payload.transcription instanceof File ||
Array.isArray(payload.transcription)
) {
console.log("Transcribing audio(s) for voice event listener.");
payload.transcription = await handleTranscription(payload.transcription);
}
}
// Check for an attached image
// Handle other_reference_data - single file or array
const otherContextData = payload?.other_reference_data;
if (
otherContextData instanceof File &&
otherContextData.type.includes("image")
) {
console.log("Got image in voice event payload; converting to base64...");
const buffer = await otherContextData.arrayBuffer();
attachedImageBase64 = `data:${otherContextData.type};base64,${Buffer.from(
if (otherContextData) {
if (Array.isArray(otherContextData)) {
const results = await Promise.all(
otherContextData.map(async (item) => {
if (item instanceof File) {
if (item.type.includes("audio")) {
return await get_transcription(item as globalThis.File);
} else if (item.type.includes("image")) {
const buffer = await item.arrayBuffer();
const imageBase64 = `data:${item.type};base64,${Buffer.from(
buffer
).toString("base64")}`;
attachedImagesBase64.push(imageBase64); // Add to array
return imageBase64;
}
}
return item;
})
);
payload.other_reference_data = results;
} else if (otherContextData instanceof File) {
if (otherContextData.type.includes("audio")) {
payload.other_reference_data = await get_transcription(
otherContextData as globalThis.File
);
} else if (otherContextData.type.includes("image")) {
const buffer = await otherContextData.arrayBuffer();
const imageBase64 = `data:${otherContextData.type};base64,${Buffer.from(
buffer
).toString("base64")}`;
attachedImagesBase64.push(imageBase64); // Add to array
}
}
}
const files = payload?.files;
if (files) {
if (Array.isArray(files)) {
const results = await Promise.all(
files.map(async (file) => {
if (file instanceof File) {
if (file.type.includes("audio")) {
const res = await get_transcription(file as globalThis.File);
return `transcript of file: ${file.name}:
${res}
`
} else if (file.type.includes("image")) {
const buffer = await file.arrayBuffer();
const imageBase64 = `data:${file.type};base64,${Buffer.from(
buffer
).toString("base64")}`;
attachedImagesBase64.push(imageBase64); // Add to array
return `image file: ${file.name}`;
}
}
return file;
})
);
payload.files = results;
} else if (files instanceof File) {
if (files.type.includes("audio")) {
// payload.files = await get_transcription(files as globalThis.File);
const res = await get_transcription(files as globalThis.File);
payload.files = `transcript of file: ${files.name}:
${res}
`;
} else if (files.type.includes("image")) {
const buffer = await files.arrayBuffer();
const imageBase64 = `data:${files.type};base64,${Buffer.from(
buffer
).toString("base64")}`;
attachedImagesBase64.push(imageBase64); // Add to array
payload.files = `image file: ${files.name}`;
}
}
}
// console.log("Payload: ", payload);
// console.log("IMages: ", attachedImagesBase64);
let message = `
You are in voice trigger mode.
@ -72,7 +162,7 @@ Your response must be in plain text without extra formatting or Markdown.
updatedSystemPrompt: prompt,
message,
updatedTools: tools,
attachedImageBase64,
attachedImagesBase64, // Now returning array
model: "gpt-4o",
};
}
@ -147,7 +237,7 @@ async function defaultAugmentation(
): Promise<PromptAugmentationResult> {
return {
updatedTools: baseTools,
attachedImageBase64: undefined,
attachedImagesBase64: [], // Changed to empty array
model: "gpt-4o-mini",
};
}
@ -187,7 +277,7 @@ export async function buildPromptAndToolsForEvent(
finalPrompt: string;
message?: string;
finalTools: RunnableToolFunctionWithParse<any>[] | undefined;
attachedImage?: string;
attachedImages?: string[];
model?: string;
}> {
console.log(`Building prompt for event: ${eventId}`);
@ -227,8 +317,7 @@ You are called when an event triggers. Your task is to execute the user's instru
- **Event ID:** ${eventId}
- **Description:** ${description}
- **Payload:** ${JSON.stringify(payload, null, 2)}
- **Will Auto Notify Creator of Listener:** ${
notify
- **Will Auto Notify Creator of Listener:** ${notify
? "Yes, no need to send it yourself"
: "No, you need to notify the user manually"
}
@ -257,7 +346,7 @@ You are called when an event triggers. Your task is to execute the user's instru
const {
additionalSystemPrompt,
updatedTools,
attachedImageBase64,
attachedImagesBase64,
updatedSystemPrompt,
model,
message,
@ -271,7 +360,7 @@ You are called when an event triggers. Your task is to execute the user's instru
return {
finalPrompt: updatedSystemPrompt || finalPrompt,
finalTools: updatedTools,
attachedImage: attachedImageBase64,
attachedImages: attachedImagesBase64,
model,
message,
};

View File

@ -434,7 +434,7 @@ function registerListener(listener: EventListener) {
console.time("buildPromptAndToolsForEvent");
// Now call the helper from the new file
const { finalPrompt, finalTools, attachedImage, model, message } =
const { finalPrompt, finalTools, attachedImages, model, message } =
await buildPromptAndToolsForEvent(
eventId,
description,
@ -457,7 +457,7 @@ function registerListener(listener: EventListener) {
model: model,
message,
prompt: finalPrompt,
image_url: attachedImage, // If there's an attached image base64
image_urls: attachedImages, // If there's an attached image base64
seed: `${eventId}-${listener.id}`,
tools: finalTools,
});

View File

@ -148,6 +148,8 @@ export function getTools(
) {
const userRoles = context_message.getUserRoles();
console.log("User roles: ", userRoles);
// Aggregate permissions from all roles
const userPermissions = new Set<string>();
userRoles.forEach((role) => {

View File

@ -13,80 +13,59 @@ export const IssueParams = z.object({
title: z.string(),
description: z.string().optional(),
assigneeId: z.string().optional(),
projectId: z.string().optional(),
priority: z.number().optional(),
labelIds: z.array(z.string()).optional(),
});
export const UpdateIssueParams = z.object({
issueId: z.string().describe("The ID of the issue to update"),
// Basic fields
title: z.string().optional().describe("The issue title"),
description: z
.string()
.optional()
.describe("The issue description in markdown format"),
stateId: z.string().optional().describe("The team state/status of the issue"),
assigneeId: z
.string()
.optional()
.describe("The identifier of the user to assign the issue to"),
priority: z
.number()
.min(0)
.max(4)
.optional()
.describe(
"The priority of the issue. 0 = No priority, 1 = Urgent, 2 = High, 3 = Normal, 4 = Low"
),
addedLabelIds: z
.array(z.string())
.optional()
description: z.string().optional().describe("The issue description in markdown format"),
descriptionData: z.any().optional().describe("The issue description as a Prosemirror document"),
priority: z.number().min(0).max(4).optional()
.describe("The priority of the issue. 0 = No priority, 1 = Urgent, 2 = High, 3 = Normal, 4 = Low"),
// Assignee and subscribers
assigneeId: z.string().optional().describe("The identifier of the user to assign the issue to"),
subscriberIds: z.array(z.string()).optional().describe("The identifiers of the users subscribing to this ticket"),
// Labels
labelIds: z.array(z.string()).optional()
.describe("The complete set of label IDs to set on the issue (replaces existing labels)"),
addedLabelIds: z.array(z.string()).optional()
.describe("The identifiers of the issue labels to be added to this issue"),
removedLabelIds: z
.array(z.string())
.optional()
.describe(
"The identifiers of the issue labels to be removed from this issue"
),
labelIds: z
.array(z.string())
.optional()
.describe(
"The complete set of label IDs to set on the issue (replaces existing labels)"
),
autoClosedByParentClosing: z
.boolean()
.optional()
.describe(
"Whether the issue was automatically closed because its parent issue was closed"
),
boardOrder: z
.number()
.optional()
.describe("The position of the issue in its column on the board view"),
dueDate: z
.string()
.optional()
.describe("The date at which the issue is due (TimelessDate format)"),
parentId: z
.string()
.optional()
.describe("The identifier of the parent issue"),
projectId: z
.string()
.optional()
.describe("The project associated with the issue"),
sortOrder: z
.number()
.optional()
.describe("The position of the issue related to other issues"),
subIssueSortOrder: z
.number()
.optional()
.describe("The position of the issue in parent's sub-issue list"),
teamId: z
.string()
.optional()
.describe("The identifier of the team associated with the issue"),
removedLabelIds: z.array(z.string()).optional()
.describe("The identifiers of the issue labels to be removed from this issue"),
// Status and workflow
stateId: z.string().optional().describe("The team state of the issue"),
estimate: z.number().optional().describe("The estimated complexity of the issue"),
// Dates and scheduling
dueDate: z.string().optional().describe("The date at which the issue is due (YYYY-MM-DD format)"),
snoozedById: z.string().optional().describe("The identifier of the user who snoozed the issue"),
snoozedUntilAt: z.string().optional().describe("The time until an issue will be snoozed in Triage view"),
// Relationships
parentId: z.string().optional().describe("The identifier of the parent issue"),
projectId: z.string().optional().describe("The project associated with the issue"),
projectMilestoneId: z.string().optional().describe("The project milestone associated with the issue"),
teamId: z.string().optional().describe("The identifier of the team associated with the issue"),
cycleId: z.string().optional().describe("The cycle associated with the issue"),
// Sorting and positioning
sortOrder: z.number().optional().describe("The position of the issue related to other issues"),
boardOrder: z.number().optional().describe("The position of the issue in its column on the board view"),
subIssueSortOrder: z.number().optional().describe("The position of the issue in parent's sub-issue list"),
prioritySortOrder: z.number().optional().describe("[ALPHA] The position of the issue when ordered by priority"),
// Templates and automation
lastAppliedTemplateId: z.string().optional().describe("The ID of the last template applied to the issue"),
autoClosedByParentClosing: z.boolean().optional()
.describe("Whether the issue was automatically closed because its parent issue was closed"),
trashed: z.boolean().optional().describe("Whether the issue has been trashed"),
});
export const GetIssueParams = z.object({
@ -103,31 +82,134 @@ export const ListTeamsParams = z.object({
limit: z.number().max(20).describe("Number of teams to return (default 3)"),
});
export const AdvancedSearchIssuesParams = z.object({
query: z.string().optional(),
teamId: z.string().optional(),
assigneeId: z.string().optional(),
status: z
.enum(["backlog", "todo", "in_progress", "done", "canceled"])
.optional(),
priority: z.number().min(0).max(4).optional(),
orderBy: z
.enum(["createdAt", "updatedAt"])
.optional()
.describe("Order by, defaults to updatedAt"),
limit: z
.number()
.max(10)
.describe("Number of results to return (default: 5)"),
// Add these type definitions before the parameter schemas
export const StringComparator = z.object({
eq: z.string().optional(),
neq: z.string().optional(),
in: z.array(z.string()).optional(),
nin: z.array(z.string()).optional(),
contains: z.string().optional(),
notContains: z.string().optional(),
startsWith: z.string().optional(),
notStartsWith: z.string().optional(),
endsWith: z.string().optional(),
notEndsWith: z.string().optional(),
containsIgnoreCase: z.string().optional(),
notContainsIgnoreCase: z.string().optional(),
startsWithIgnoreCase: z.string().optional(),
notStartsWithIgnoreCase: z.string().optional(),
endsWithIgnoreCase: z.string().optional(),
notEndsWithIgnoreCase: z.string().optional(),
});
export const DateComparator = z.object({
eq: z.string().optional(),
neq: z.string().optional(),
gt: z.string().optional(),
gte: z.string().optional(),
lt: z.string().optional(),
lte: z.string().optional(),
in: z.array(z.string()).optional(),
nin: z.array(z.string()).optional(),
});
export const NumberComparator = z.object({
eq: z.number().optional(),
neq: z.number().optional(),
gt: z.number().optional(),
gte: z.number().optional(),
lt: z.number().optional(),
lte: z.number().optional(),
in: z.array(z.number()).optional(),
nin: z.array(z.number()).optional(),
});
export const IdComparator = z.object({
eq: z.string().optional(),
neq: z.string().optional(),
in: z.array(z.string()).optional(),
nin: z.array(z.string()).optional(),
});
export const WorkflowStateFilter = z.object({
createdAt: DateComparator.optional(),
description: StringComparator.optional(),
id: IdComparator.optional(),
name: StringComparator.optional(),
position: NumberComparator.optional(),
type: StringComparator.optional(),
updatedAt: DateComparator.optional(),
});
export const AdvancedSearchIssuesParams = z.object({
// Text search
query: z.string().optional().describe("Search in title and description"),
title: z.string().optional().describe("Filter by exact or partial title match"),
description: z.string().optional().describe("Filter by description content"),
// Basic filters
teamId: z.string().optional().describe("Filter by team ID"),
assigneeId: z.string().optional().describe("Filter by assignee user ID"),
creatorId: z.string().optional().describe("Filter by creator user ID"),
priority: z.number().min(0).max(4).optional()
.describe("0 = No priority, 1 = Urgent, 2 = High, 3 = Normal, 4 = Low"),
// Status and state
stateId: z.string().optional().describe("Filter by specific workflow state ID (simplified)"),
// Dates
createdAfter: z.string().optional().describe("Issues created after this ISO datetime"),
createdBefore: z.string().optional().describe("Issues created before this ISO datetime"),
updatedAfter: z.string().optional().describe("Issues updated after this ISO datetime"),
updatedBefore: z.string().optional().describe("Issues updated before this ISO datetime"),
completedAfter: z.string().optional().describe("Issues completed after this ISO datetime"),
completedBefore: z.string().optional().describe("Issues completed before this ISO datetime"),
dueDate: z.string().optional().describe("Filter by due date (YYYY-MM-DD format)"),
dueDateAfter: z.string().optional().describe("Due date after (YYYY-MM-DD format)"),
dueDateBefore: z.string().optional().describe("Due date before (YYYY-MM-DD format)"),
startedAfter: z.string().optional().describe("Issues started after this ISO datetime"),
startedBefore: z.string().optional().describe("Issues started before this ISO datetime"),
// Relationships
projectId: z.string().optional().describe("Filter by project ID"),
parentId: z.string().optional().describe("Filter by parent issue ID"),
subscriberId: z.string().optional().describe("Filter by subscriber user ID"),
hasBlockedBy: z.boolean().optional().describe("Issues that are blocked by others"),
hasBlocking: z.boolean().optional().describe("Issues that are blocking others"),
hasDuplicates: z.boolean().optional().describe("Issues that have duplicates"),
// Labels and estimates
labelIds: z.array(z.string()).optional().describe("Filter by one or more label IDs"),
estimate: z.number().optional().describe("Filter by issue estimate points"),
// Other filters
number: z.number().optional().describe("Filter by issue number"),
snoozedById: z.string().optional().describe("Filter by user who snoozed the issue"),
snoozedUntilAfter: z.string().optional().describe("Issues snoozed until after this ISO datetime"),
snoozedUntilBefore: z.string().optional().describe("Issues snoozed until before this ISO datetime"),
// Result options
orderBy: z.enum(["createdAt", "updatedAt", "priority", "dueDate"])
.optional()
.describe("Sort order for results"),
limit: z.number().max(10)
.describe("Number of results to return (default: 2, max: 10)"),
});
// Modify SearchUsersParams schema to allow more specific search parameters
export const SearchUsersParams = z.object({
query: z.string().describe("Search query for user names"),
email: z.string().optional().describe("Search by exact email address"),
displayName: z.string().optional().describe("Search by display name"),
limit: z
.number()
.max(10)
.describe("Number of results to return (default: 5)"),
});
}).refine(
data => (data.email && !data.displayName) || (!data.email && data.displayName),
{
message: "Provide either email OR displayName, not both"
}
);
// Add new Project Parameter Schemas
export const ProjectParams = z.object({
@ -207,9 +289,45 @@ export const GetProjectParams = z.object({
});
export const SearchProjectsParams = z.object({
query: z.string().describe("Search query string"),
teamId: z.string().optional(),
limit: z.number().max(5).describe("Number of results to return (default: 1)"),
// Text search
query: z.string().optional().describe("Search in project name and content"),
name: z.string().optional().describe("Filter by exact or partial project name"),
// Basic filters
teamId: z.string().optional().describe("Filter by team ID"),
creatorId: z.string().optional().describe("Filter by creator user ID"),
leadId: z.string().optional().describe("Filter by lead user ID"),
priority: z.number().min(0).max(4).optional()
.describe("0 = No priority, 1 = Urgent, 2 = High, 3 = Normal, 4 = Low"),
// Status and state
health: z.string().optional().describe("Filter by project health status"),
state: z.string().optional().describe("[DEPRECATED] Filter by project state"),
status: z.string().optional().describe("Filter by project status ID"),
// Dates
startDate: z.string().optional().describe("Filter by start date"),
targetDate: z.string().optional().describe("Filter by target date"),
createdAfter: z.string().optional().describe("Projects created after this ISO datetime"),
createdBefore: z.string().optional().describe("Projects created before this ISO datetime"),
updatedAfter: z.string().optional().describe("Projects updated after this ISO datetime"),
updatedBefore: z.string().optional().describe("Projects updated before this ISO datetime"),
completedAfter: z.string().optional().describe("Projects completed after this ISO datetime"),
completedBefore: z.string().optional().describe("Projects completed before this ISO datetime"),
canceledAfter: z.string().optional().describe("Projects canceled after this ISO datetime"),
canceledBefore: z.string().optional().describe("Projects canceled before this ISO datetime"),
// Relationships
hasBlockedBy: z.boolean().optional().describe("Projects that are blocked by others"),
hasBlocking: z.boolean().optional().describe("Projects that are blocking others"),
hasRelated: z.boolean().optional().describe("Projects that have related items"),
hasViolatedDependencies: z.boolean().optional().describe("Projects with violated dependencies"),
// Result options
orderBy: z.enum(["createdAt", "updatedAt", "priority", "targetDate"])
.optional()
.describe("Sort order for results"),
limit: z.number().max(10).describe("Number of results to return (default: 1)"),
});
// Add new ListProjectsParams schema after other params
@ -225,10 +343,49 @@ export const ListProjectsParams = z.object({
.describe("Filter projects by state"),
});
// Add after other parameter schemas
export const CreateCommentParams = z.object({
issueId: z.string().describe("The ID of the issue to comment on"),
body: z.string().describe("The comment text in markdown format"),
});
export const ListCommentsParams = z.object({
issueId: z.string().describe("The ID of the issue to get comments from"),
limit: z.number().max(20).describe("Number of comments to return (default: 10)"),
});
// Add new document parameter schemas
export const CreateDocumentParams = z.object({
title: z.string().describe("The title of the document"),
content: z.string().describe("The content of the document in markdown format"),
icon: z.string().optional().describe("The icon of the document"),
organizationId: z.string().optional().describe("The organization ID"),
projectId: z.string().optional().describe("The project ID to link the document to"),
});
export const UpdateDocumentParams = z.object({
documentId: z.string().describe("The ID of the document to update"),
title: z.string().optional().describe("The new title of the document"),
content: z.string().optional().describe("The new content in markdown format"),
icon: z.string().optional().describe("The new icon of the document"),
});
export const GetDocumentParams = z.object({
documentId: z.string().describe("The ID of the document to retrieve"),
});
export const SearchDocumentsParams = z.object({
query: z.string().describe("Search query string"),
projectId: z.string().optional().describe("Filter by project ID"),
limit: z.number().max(10).describe("Number of results to return (default: 5)"),
});
interface SimpleIssue {
id: string;
id: string; // The internal UUID of the issue (e.g., "123e4567-e89b-12d3-a456-426614174000")
identifier: string; // The human-readable identifier (e.g., "XCE-205")
title: string;
status: string;
statusId: string;
priority: number;
assignee?: string;
dueDate?: string;
@ -265,15 +422,42 @@ interface SimpleProject {
statusId?: string;
}
// Add new document interface
interface SimpleDocument {
id: string;
title: string;
content: string;
icon?: string;
url: string;
createdAt: string;
updatedAt: string;
}
// Add after other interfaces
interface SimpleComment {
id: string;
body: string;
user?: {
id: string;
name: string;
};
createdAt: string;
}
function formatIssue(issue: any): SimpleIssue {
return {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
status: issue.state?.name || "Unknown",
statusId: issue.state?.id,
priority: issue.priority,
assignee: issue.assignee?.name,
dueDate: issue.dueDate,
labels: issue.labels?.nodes?.map((l: any) => l.name) || [],
labels: issue.labels?.nodes?.map((l: any) => ({
name: l.name,
id: l.id
})) || [],
};
}
@ -296,6 +480,32 @@ function formatProject(project: any): SimpleProject {
};
}
// Add new document formatting function
function formatDocument(doc: any): SimpleDocument {
return {
id: doc.id,
title: doc.title,
content: doc.content,
icon: doc.icon,
url: doc.url,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
};
}
// Add after other formatting functions
function formatComment(comment: any): SimpleComment {
return {
id: comment.id,
body: comment.body,
user: comment.user ? {
id: comment.user.id,
name: comment.user.name,
} : undefined,
createdAt: comment.createdAt,
};
}
// API Functions
async function createIssue(
client: LinearClient,
@ -314,7 +524,29 @@ async function updateIssue(
) {
try {
const { issueId, ...updateData } = params;
return await client.updateIssue(issueId, updateData);
// Create a new object for the properly typed data
const formattedData: any = { ...updateData };
// Convert date strings to proper format if provided
if (formattedData.dueDate) {
// Due date should be YYYY-MM-DD format
formattedData.dueDate = formattedData.dueDate.split('T')[0];
}
if (formattedData.snoozedUntilAt) {
// Convert to Date object for the API
formattedData.snoozedUntilAt = new Date(formattedData.snoozedUntilAt);
}
// Remove any undefined values to avoid API errors
Object.keys(formattedData).forEach(key => {
if (formattedData[key] === undefined) {
delete formattedData[key];
}
});
return await client.updateIssue(issueId, formattedData);
} catch (error) {
return `Error: ${error}`;
}
@ -380,21 +612,67 @@ async function advancedSearchIssues(
) {
try {
const filter: any = {};
if (params.teamId) filter.team = { id: { eq: params.teamId } };
if (params.assigneeId) filter.assignee = { id: { eq: params.assigneeId } };
if (params.status) filter.state = { type: { eq: params.status } };
if (params.priority) filter.priority = { eq: params.priority };
// Text search filters
if (params.query) {
filter.or = [
{ title: { containsIgnoreCase: params.query } },
{ description: { containsIgnoreCase: params.query } },
];
}
if (params.title) filter.title = { containsIgnoreCase: params.title };
if (params.description) filter.description = { containsIgnoreCase: params.description };
// Basic filters
if (params.teamId) filter.team = { id: { eq: params.teamId } };
if (params.assigneeId) filter.assignee = { id: { eq: params.assigneeId } };
if (params.creatorId) filter.creator = { id: { eq: params.creatorId } };
if (params.priority !== undefined) filter.priority = { eq: params.priority };
// Status and state
if (params.stateId) {
filter.state = { id: { eq: params.stateId } };
}
// Date filters
if (params.createdAfter) filter.createdAt = { gt: params.createdAfter };
if (params.createdBefore) filter.createdAt = { lt: params.createdBefore };
if (params.updatedAfter) filter.updatedAt = { gt: params.updatedAfter };
if (params.updatedBefore) filter.updatedAt = { lt: params.updatedBefore };
if (params.completedAfter) filter.completedAt = { gt: params.completedAfter };
if (params.completedBefore) filter.completedAt = { lt: params.completedBefore };
if (params.startedAfter) filter.startedAt = { gt: params.startedAfter };
if (params.startedBefore) filter.startedAt = { lt: params.startedBefore };
// Due date filters
if (params.dueDate) filter.dueDate = { eq: params.dueDate };
if (params.dueDateAfter) filter.dueDate = { gt: params.dueDateAfter };
if (params.dueDateBefore) filter.dueDate = { lt: params.dueDateBefore };
// Relationship filters
if (params.projectId) filter.project = { id: { eq: params.projectId } };
if (params.parentId) filter.parent = { id: { eq: params.parentId } };
if (params.subscriberId) filter.subscribers = { some: { id: { eq: params.subscriberId } } };
if (params.hasBlockedBy) filter.hasBlockedByRelations = { eq: true };
if (params.hasBlocking) filter.hasBlockingRelations = { eq: true };
if (params.hasDuplicates) filter.hasDuplicateRelations = { eq: true };
// Labels
if (params.labelIds?.length) {
filter.labels = { some: { id: { in: params.labelIds } } };
}
// Other filters
if (params.estimate !== undefined) filter.estimate = { eq: params.estimate };
if (params.number !== undefined) filter.number = { eq: params.number };
if (params.snoozedById) filter.snoozedBy = { id: { eq: params.snoozedById } };
if (params.snoozedUntilAfter) filter.snoozedUntilAt = { gt: params.snoozedUntilAfter };
if (params.snoozedUntilBefore) filter.snoozedUntilAt = { lt: params.snoozedUntilBefore };
const issues = await client.issues({
first: params.limit,
filter,
orderBy: params.orderBy || ("updatedAt" as any),
orderBy: params.orderBy || "updatedAt" as any,
});
return issues.nodes.map(formatIssue);
@ -403,20 +681,23 @@ async function advancedSearchIssues(
}
}
// Modify searchUsers function to allow more specific search parameters
async function searchUsers(
client: LinearClient,
{ query, limit }: z.infer<typeof SearchUsersParams>
params: z.infer<typeof SearchUsersParams>
) {
try {
let filter: any = {};
if (params.email) {
filter = { email: { eq: params.email } };
} else if (params.displayName) {
filter = { displayName: { containsIgnoreCase: params.displayName } };
}
const users = await client.users({
filter: {
or: [
{ name: { containsIgnoreCase: query } },
{ displayName: { containsIgnoreCase: query } },
{ email: { containsIgnoreCase: query } },
],
},
first: limit,
filter,
first: params.limit,
});
return users.nodes.map(
@ -471,25 +752,99 @@ async function getProject(
// Modify searchProjects function to handle empty queries
async function searchProjects(
client: LinearClient,
{ query, teamId, limit }: z.infer<typeof SearchProjectsParams>
params: z.infer<typeof SearchProjectsParams>
) {
try {
const searchParams: any = { first: limit };
const filter: any = {};
if (teamId) {
filter.team = { id: { eq: teamId } };
// Text search filters
if (params.query) {
filter.or = [
{ name: { containsIgnoreCase: params.query } },
{ searchableContent: { contains: params.query } }
];
}
if (params.name) {
filter.name = { containsIgnoreCase: params.name };
}
if (query) {
filter.or = [{ name: { containsIgnoreCase: query } }];
// Basic filters
if (params.teamId) {
filter.accessibleTeams = { some: { id: { eq: params.teamId } } };
}
if (params.creatorId) {
filter.creator = { id: { eq: params.creatorId } };
}
if (params.leadId) {
filter.lead = { id: { eq: params.leadId } };
}
if (params.priority !== undefined) {
filter.priority = { eq: params.priority };
}
if (Object.keys(filter).length > 0) {
searchParams.filter = filter;
// Status and state filters
if (params.health) {
filter.health = { eq: params.health };
}
if (params.state) {
filter.state = { eq: params.state };
}
if (params.status) {
filter.status = { id: { eq: params.status } };
}
const projects = await client.projects(searchParams);
// Date filters
if (params.startDate) {
filter.startDate = { eq: params.startDate };
}
if (params.targetDate) {
filter.targetDate = { eq: params.targetDate };
}
if (params.createdAfter || params.createdBefore) {
filter.createdAt = {
...(params.createdAfter && { gt: params.createdAfter }),
...(params.createdBefore && { lt: params.createdBefore })
};
}
if (params.updatedAfter || params.updatedBefore) {
filter.updatedAt = {
...(params.updatedAfter && { gt: params.updatedAfter }),
...(params.updatedBefore && { lt: params.updatedBefore })
};
}
if (params.completedAfter || params.completedBefore) {
filter.completedAt = {
...(params.completedAfter && { gt: params.completedAfter }),
...(params.completedBefore && { lt: params.completedBefore })
};
}
if (params.canceledAfter || params.canceledBefore) {
filter.canceledAt = {
...(params.canceledAfter && { gt: params.canceledAfter }),
...(params.canceledBefore && { lt: params.canceledBefore })
};
}
// Relationship filters
if (params.hasBlockedBy) {
filter.hasBlockedByRelations = { eq: true };
}
if (params.hasBlocking) {
filter.hasBlockingRelations = { eq: true };
}
if (params.hasRelated) {
filter.hasRelatedRelations = { eq: true };
}
if (params.hasViolatedDependencies) {
filter.hasViolatedRelations = { eq: true };
}
const projects = await client.projects({
first: params.limit,
filter,
orderBy: params.orderBy || "updatedAt" as any,
});
return projects.nodes.map(formatProject);
} catch (error) {
return `Error: ${error}`;
@ -524,6 +879,106 @@ async function listProjects(
}
}
// Add new comment API functions before the main manager function
async function createComment(
client: LinearClient,
params: z.infer<typeof CreateCommentParams>
) {
try {
const { issueId, body } = params;
const comment = await client.createComment({
issueId,
body,
});
return formatComment(comment);
} catch (error) {
return `Error: ${error}`;
}
}
async function listComments(
client: LinearClient,
params: z.infer<typeof ListCommentsParams>
) {
try {
const { issueId, limit } = params;
const issue = await client.issue(issueId);
const comments = await issue.comments({
first: limit,
orderBy: "createdAt" as any,
});
return comments.nodes.map(formatComment);
} catch (error) {
return `Error: ${error}`;
}
}
// Add new document API functions before the main manager function
async function createDocument(
client: LinearClient,
params: z.infer<typeof CreateDocumentParams>
) {
try {
const document = await client.createDocument(params);
return formatDocument(document);
} catch (error) {
return `Error: ${error}`;
}
}
async function updateDocument(
client: LinearClient,
params: z.infer<typeof UpdateDocumentParams>
) {
try {
const { documentId, ...updateData } = params;
const document = await client.updateDocument(documentId, updateData);
return formatDocument(document);
} catch (error) {
return `Error: ${error}`;
}
}
async function getDocument(
client: LinearClient,
{ documentId }: z.infer<typeof GetDocumentParams>
) {
try {
const document = await client.document(documentId);
return formatDocument(document);
} catch (error) {
return `Error: ${error}`;
}
}
async function searchDocuments(
client: LinearClient,
params: z.infer<typeof SearchDocumentsParams>
) {
try {
const filter: any = {
or: [
{ title: { containsIgnoreCase: params.query } },
{ content: { containsIgnoreCase: params.query } },
],
};
if (params.projectId) {
filter.project = { id: { eq: params.projectId } };
}
const documents = await client.documents({
first: params.limit,
filter,
orderBy: "updatedAt" as any,
});
return documents.nodes.map(formatDocument);
} catch (error) {
return `Error: ${error}`;
}
}
// Main manager function
export const LinearManagerParams = z.object({
request: z
@ -536,11 +991,7 @@ export async function linearManager(
{ request }: LinearManagerParams,
context_message: Message
) {
console.log(
"Context message",
context_message.author,
context_message.getUserRoles()
);
const userConfig = context_message.author.config;
// console.log("User config", userConfig);
@ -563,6 +1014,7 @@ export async function linearManager(
const client = new LinearClient({ apiKey: linearApiKey });
const linear_tools: RunnableToolFunction<any>[] = [
zodFunction({
function: (params) => createIssue(client, params),
@ -606,8 +1058,16 @@ export async function linearManager(
function: (params) => advancedSearchIssues(client, params),
name: "linearAdvancedSearchIssues",
schema: AdvancedSearchIssuesParams,
description:
"Search for issues with advanced filters including status, assignee, and priority",
description: `Search for issues with advanced filters including:
- Status (backlog, todo, in_progress, done, canceled)
- Assignee
- Priority
- Date ranges for:
* Updated time
* Created time
* Completed time
Use ISO datetime format (e.g., "2024-01-18T00:00:00Z") for date filters.
Can find issues updated, created, or completed within specific time periods.`,
}),
zodFunction({
function: (params) => createProject(client, params),
@ -641,8 +1101,48 @@ export async function linearManager(
description:
"List projects in Linear, optionally filtered by team and state. Returns most recently updated projects first.",
}),
zodFunction({
function: (params) => createComment(client, params),
name: "linearCreateComment",
schema: CreateCommentParams,
description: "Create a new comment on a Linear issue",
}),
zodFunction({
function: (params) => listComments(client, params),
name: "linearListComments",
schema: ListCommentsParams,
description: "List comments on a Linear issue",
}),
zodFunction({
function: (params) => createDocument(client, params),
name: "linearCreateDocument",
schema: CreateDocumentParams,
description: "Create a new document in Linear",
}),
zodFunction({
function: (params) => updateDocument(client, params),
name: "linearUpdateDocument",
schema: UpdateDocumentParams,
description: "Update an existing document in Linear",
}),
zodFunction({
function: (params) => getDocument(client, params),
name: "linearGetDocument",
schema: GetDocumentParams,
description: "Get details of a specific document",
}),
zodFunction({
function: (params) => searchDocuments(client, params),
name: "linearSearchDocuments",
schema: SearchDocumentsParams,
description: "Search for documents in Linear using a query string",
}),
];
const organization = await client.organization
const workspace = organization?.name
// fetch all labels available in each team
const teams = await client.teams({ first: 10 });
const teamLabels = await client.issueLabels();
@ -654,54 +1154,65 @@ export async function linearManager(
name: state.name,
}));
const organizationContext = `Organization:
Name: ${workspace}
Id: ${organization?.id}
`;
// Only include teams and labels in the context if they exist
const teamsContext =
teams.nodes.length > 0
? `Teams:\n${teams.nodes.map((team) => ` - ${team.name}`).join("\n")}`
? `Teams:\n${teams.nodes.map((team) => ` - ${team.name} id: ${team.id}`).join("\n")}`
: "";
const labelsContext =
teamLabels.nodes.length > 0
? `All Labels:\n${teamLabels.nodes
.map((label) => ` - ${label.name} (${label.color})`)
.map((label) => ` - ${label.name} (${label.color}) id: ${label.id}`)
.join("\n")}`
: "";
const issueStateContext =
state_values.length > 0
? `All Issue States:\n${state_values
.map((state) => ` - ${state.name}`)
.map((state) => ` - ${state.name} id: ${state.id}`)
.join("\n")}`
: "";
const workspaceContext = [teamsContext, labelsContext, issueStateContext]
const workspaceContext = [organizationContext, teamsContext, labelsContext, issueStateContext]
.filter(Boolean)
.join("\n\n");
const userDetails = await client.users({ filter: { email: { eq: linearEmail } } });
const response = await ask({
model: "gpt-4o-mini",
model: "gpt-4o",
prompt: `You are a Linear project manager.
Your job is to understand the user's request and manage issues, teams, and projects using the available tools.
Important note about Linear issue identification:
- issueId: A UUID that uniquely identifies an issue internally (e.g., "123e4567-e89b-12d3-a456-426614174000")
- identifier: A human-readable issue reference (e.g., "XCE-205", "ENG-123")
When referring to issues in responses, always use the identifier format for better readability.
----
${memory_manager_guide("linear_manager", context_message.author.id)}
----
${
workspaceContext
${workspaceContext
? `Here is some more context on current linear workspace:\n${workspaceContext}`
: ""
}
The user you are currently assisting has the following details:
The user you are currently assisting has the following details (No need to search if the user is asking for their own related issues/projects):
- Name: ${userConfig?.name}
- Linear Email: ${linearEmail}
- Linear User ID: ${userDetails.nodes[0]?.id}
When responding make sure to link the issues when returning the value.
linear issue links look like: \`https://linear.app/xcelerator/issue/XCE-205\`
Where \`XCE-205\` is the issue ID and \`xcelerator\` is the team name.
Where \`XCE-205\` is the identifier (not the issueId) and \`xcelerator\` is the team name.
`,
message: request,
seed: `linear-${context_message.channelId}`,

View File

@ -416,7 +416,7 @@ ${memory_manager_guide("links_manager", context_message.author.id)}
----
`,
message: request,
seed: "link-${context_message.channelId}",
seed: `link-${context_message.channelId}`,
tools: link_tools.concat(
memory_manager_init(context_message, "links_manager")
) as any,