Initial backup 2026-02-17
This commit is contained in:
310
skills/lark-calendar/lib/calendar.mjs
Normal file
310
skills/lark-calendar/lib/calendar.mjs
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Lark Calendar Event Operations
|
||||
*/
|
||||
|
||||
import { larkApi } from './lark-api.mjs';
|
||||
import { ensureBoyangIncluded, getDisplayName } from './employees.mjs';
|
||||
|
||||
// Default calendar ID (Claw calendar)
|
||||
export const DEFAULT_CALENDAR_ID = 'feishu.cn_caF80RJxgGcbBGsQx64bCh@group.calendar.feishu.cn';
|
||||
|
||||
// Default timezone
|
||||
export const DEFAULT_TIMEZONE = 'Asia/Singapore';
|
||||
|
||||
/**
|
||||
* Convert datetime string to Unix timestamp
|
||||
* @param {string} timeStr - Format: YYYY-MM-DD HH:MM:SS
|
||||
* @param {string} timezone - IANA timezone (e.g., Asia/Singapore)
|
||||
* @returns {number} - Unix timestamp in seconds
|
||||
*/
|
||||
export function datetimeToTimestamp(timeStr, timezone = DEFAULT_TIMEZONE) {
|
||||
// Parse the datetime string
|
||||
const [datePart, timePart] = timeStr.split(' ');
|
||||
const [year, month, day] = datePart.split('-').map(Number);
|
||||
const [hour, minute, second] = (timePart || '00:00:00').split(':').map(Number);
|
||||
|
||||
// Create date in the specified timezone
|
||||
// JavaScript Date uses local time, so we need to handle timezone offset
|
||||
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second || 0).padStart(2, '0')}`;
|
||||
|
||||
// Use Intl to get the timezone offset
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
|
||||
// Create a date object assuming the input is in the target timezone
|
||||
// We need to find the UTC equivalent
|
||||
const localDate = new Date(dateStr);
|
||||
|
||||
// Get the offset for this timezone at this date
|
||||
const utcDate = new Date(localDate.toLocaleString('en-US', { timeZone: 'UTC' }));
|
||||
const tzDate = new Date(localDate.toLocaleString('en-US', { timeZone: timezone }));
|
||||
const offset = utcDate - tzDate;
|
||||
|
||||
// Adjust and return timestamp
|
||||
return Math.floor((localDate.getTime() + offset) / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Unix timestamp to datetime string
|
||||
* @param {number} timestamp - Unix timestamp in seconds
|
||||
* @param {string} timezone - IANA timezone
|
||||
* @returns {string} - Format: YYYY-MM-DD HH:MM:SS
|
||||
*/
|
||||
export function timestampToDatetime(timestamp, timezone = DEFAULT_TIMEZONE) {
|
||||
const date = new Date(timestamp * 1000);
|
||||
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
|
||||
const parts = formatter.formatToParts(date);
|
||||
const values = {};
|
||||
for (const part of parts) {
|
||||
values[part.type] = part.value;
|
||||
}
|
||||
|
||||
return `${values.year}-${values.month}-${values.day} ${values.hour}:${values.minute}:${values.second}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a calendar event
|
||||
* @param {object} options
|
||||
* @param {string} options.title - Event title
|
||||
* @param {string} [options.description] - Event description
|
||||
* @param {string} options.startTime - Start time (YYYY-MM-DD HH:MM:SS)
|
||||
* @param {string} options.endTime - End time (YYYY-MM-DD HH:MM:SS)
|
||||
* @param {string[]} [options.attendeeIds] - Array of user_ids
|
||||
* @param {string} [options.location] - Event location
|
||||
* @param {string} [options.timezone] - IANA timezone
|
||||
* @param {string} [options.calendarId] - Calendar ID
|
||||
* @returns {object} - Created event data
|
||||
*/
|
||||
export async function createEvent({
|
||||
title,
|
||||
description = '',
|
||||
startTime,
|
||||
endTime,
|
||||
attendeeIds = [],
|
||||
location = '',
|
||||
timezone = DEFAULT_TIMEZONE,
|
||||
calendarId = DEFAULT_CALENDAR_ID
|
||||
}) {
|
||||
// Always include Boyang
|
||||
const finalAttendeeIds = ensureBoyangIncluded(attendeeIds);
|
||||
|
||||
// Convert times to timestamps
|
||||
const startTimestamp = datetimeToTimestamp(startTime, timezone);
|
||||
const endTimestamp = datetimeToTimestamp(endTime, timezone);
|
||||
|
||||
// Build location params
|
||||
const locationParams = location ? { name: location } : null;
|
||||
|
||||
// Create the event
|
||||
const eventData = await larkApi('POST', `/calendar/v4/calendars/${calendarId}/events`, {
|
||||
data: {
|
||||
summary: title,
|
||||
description: description,
|
||||
start_time: { timestamp: String(startTimestamp), timezone },
|
||||
end_time: { timestamp: String(endTimestamp), timezone },
|
||||
visibility: 'private',
|
||||
attendee_ability: 'can_modify_event',
|
||||
free_busy_status: 'busy',
|
||||
reminders: [],
|
||||
schemas: [],
|
||||
attachments: [],
|
||||
color: -1,
|
||||
recurrence: null,
|
||||
location: locationParams
|
||||
}
|
||||
});
|
||||
|
||||
const event = eventData.event;
|
||||
|
||||
// Add attendees if any
|
||||
let attendees = [];
|
||||
if (finalAttendeeIds.length > 0) {
|
||||
const attendeesData = await addEventAttendees(calendarId, event.event_id, finalAttendeeIds);
|
||||
attendees = attendeesData.attendees || [];
|
||||
}
|
||||
|
||||
return {
|
||||
event,
|
||||
attendees,
|
||||
attendeeNames: attendees.map(a => a.display_name).join(', ')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a calendar event
|
||||
* @param {object} options
|
||||
* @param {string} options.eventId - Event ID
|
||||
* @param {string} [options.title] - Event title
|
||||
* @param {string} [options.description] - Event description
|
||||
* @param {string} [options.startTime] - Start time (YYYY-MM-DD HH:MM:SS)
|
||||
* @param {string} [options.endTime] - End time (YYYY-MM-DD HH:MM:SS)
|
||||
* @param {string} [options.location] - Event location
|
||||
* @param {string} [options.timezone] - IANA timezone
|
||||
* @param {string} [options.calendarId] - Calendar ID
|
||||
* @returns {object} - Updated event data
|
||||
*/
|
||||
export async function updateEvent({
|
||||
eventId,
|
||||
title,
|
||||
description,
|
||||
startTime,
|
||||
endTime,
|
||||
location,
|
||||
timezone = DEFAULT_TIMEZONE,
|
||||
calendarId = DEFAULT_CALENDAR_ID
|
||||
}) {
|
||||
const updateData = {};
|
||||
|
||||
if (title !== undefined) updateData.summary = title;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
if (location !== undefined) updateData.location = location ? { name: location } : null;
|
||||
|
||||
if (startTime) {
|
||||
const startTimestamp = datetimeToTimestamp(startTime, timezone);
|
||||
updateData.start_time = { timestamp: String(startTimestamp), timezone };
|
||||
}
|
||||
|
||||
if (endTime) {
|
||||
const endTimestamp = datetimeToTimestamp(endTime, timezone);
|
||||
updateData.end_time = { timestamp: String(endTimestamp), timezone };
|
||||
}
|
||||
|
||||
const result = await larkApi('PATCH', `/calendar/v4/calendars/${calendarId}/events/${eventId}`, {
|
||||
data: updateData
|
||||
});
|
||||
|
||||
return result.event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a calendar event
|
||||
* @param {string} eventId - Event ID
|
||||
* @param {string} [calendarId] - Calendar ID
|
||||
* @param {boolean} [notify] - Send notification to attendees
|
||||
* @returns {boolean} - Success
|
||||
*/
|
||||
export async function deleteEvent(eventId, calendarId = DEFAULT_CALENDAR_ID, notify = true) {
|
||||
await larkApi('DELETE', `/calendar/v4/calendars/${calendarId}/events/${eventId}`, {
|
||||
params: { need_notification: String(notify) }
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add attendees to an event
|
||||
* @param {string} calendarId - Calendar ID
|
||||
* @param {string} eventId - Event ID
|
||||
* @param {string[]} userIds - Array of user_ids
|
||||
* @returns {object} - Attendees data
|
||||
*/
|
||||
export async function addEventAttendees(calendarId, eventId, userIds) {
|
||||
const attendees = userIds.map(userId => ({
|
||||
type: 'user',
|
||||
is_optional: true,
|
||||
user_id: userId
|
||||
}));
|
||||
|
||||
return larkApi('POST', `/calendar/v4/calendars/${calendarId}/events/${eventId}/attendees`, {
|
||||
params: { user_id_type: 'user_id' },
|
||||
data: {
|
||||
attendees,
|
||||
need_notification: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove attendees from an event
|
||||
* @param {string} calendarId - Calendar ID
|
||||
* @param {string} eventId - Event ID
|
||||
* @param {string[]} userIds - Array of user_ids to remove
|
||||
* @returns {object} - Result
|
||||
*/
|
||||
export async function removeEventAttendees(calendarId, eventId, userIds) {
|
||||
const deleteIds = userIds.map(userId => ({
|
||||
type: 'user',
|
||||
is_optional: true,
|
||||
user_id: userId
|
||||
}));
|
||||
|
||||
return larkApi('POST', `/calendar/v4/calendars/${calendarId}/events/${eventId}/attendees/batch_delete`, {
|
||||
params: { user_id_type: 'user_id' },
|
||||
data: {
|
||||
delete_ids: deleteIds,
|
||||
need_notification: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List events in a calendar
|
||||
* @param {object} options
|
||||
* @param {string} [options.calendarId] - Calendar ID
|
||||
* @param {string} [options.startTime] - Start of range (YYYY-MM-DD or YYYY-MM-DD HH:MM:SS)
|
||||
* @param {string} [options.endTime] - End of range
|
||||
* @param {string} [options.timezone] - Timezone
|
||||
* @returns {object[]} - Array of events
|
||||
*/
|
||||
export async function listEvents({
|
||||
calendarId = DEFAULT_CALENDAR_ID,
|
||||
startTime,
|
||||
endTime,
|
||||
timezone = DEFAULT_TIMEZONE
|
||||
} = {}) {
|
||||
// Default to next 7 days if not specified
|
||||
const now = new Date();
|
||||
if (!startTime) {
|
||||
startTime = now.toISOString().split('T')[0] + ' 00:00:00';
|
||||
} else if (!startTime.includes(' ')) {
|
||||
startTime += ' 00:00:00';
|
||||
}
|
||||
|
||||
if (!endTime) {
|
||||
const weekLater = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
endTime = weekLater.toISOString().split('T')[0] + ' 23:59:59';
|
||||
} else if (!endTime.includes(' ')) {
|
||||
endTime += ' 23:59:59';
|
||||
}
|
||||
|
||||
const startTs = datetimeToTimestamp(startTime, timezone);
|
||||
const endTs = datetimeToTimestamp(endTime, timezone);
|
||||
|
||||
const result = await larkApi('GET', `/calendar/v4/calendars/${calendarId}/events`, {
|
||||
params: {
|
||||
start_time: String(startTs),
|
||||
end_time: String(endTs),
|
||||
page_size: 50
|
||||
}
|
||||
});
|
||||
|
||||
return result.items || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single event
|
||||
* @param {string} eventId - Event ID
|
||||
* @param {string} [calendarId] - Calendar ID
|
||||
* @returns {object} - Event data
|
||||
*/
|
||||
export async function getEvent(eventId, calendarId = DEFAULT_CALENDAR_ID) {
|
||||
const result = await larkApi('GET', `/calendar/v4/calendars/${calendarId}/events/${eventId}`);
|
||||
return result.event;
|
||||
}
|
||||
241
skills/lark-calendar/lib/employees.mjs
Normal file
241
skills/lark-calendar/lib/employees.mjs
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Employee Directory - Dynamic lookup via Lark Contact API
|
||||
*
|
||||
* Uses Lark's contact API to resolve names to user_ids dynamically.
|
||||
* Falls back to cached data for known employees.
|
||||
*/
|
||||
|
||||
import { larkApi } from './lark-api.mjs';
|
||||
|
||||
// Boyang's user_id - always added as attendee
|
||||
export const BOYANG_USER_ID = 'dgg163e1';
|
||||
|
||||
// Cache for employee data (populated on first lookup)
|
||||
let employeeCache = null;
|
||||
let cacheTimestamp = 0;
|
||||
const CACHE_TTL = 3600000; // 1 hour
|
||||
|
||||
// Fallback static data for known employees (used when API fails)
|
||||
// Update this when team changes, or add contact:contact:readonly permission for dynamic lookup
|
||||
const FALLBACK_EMPLOYEES = {
|
||||
'dgg163e1': { user_id: 'dgg163e1', name: 'Boyang', en_name: 'Boyang', nickname: 'by' },
|
||||
'gb71g28b': { user_id: 'gb71g28b', name: 'RK', en_name: 'RK' },
|
||||
'53gc5724': { user_id: '53gc5724', name: 'Ding', en_name: 'Ding' },
|
||||
'217ec2c2': { user_id: '217ec2c2', name: 'Charline', en_name: 'Charline' },
|
||||
'f2bfd283': { user_id: 'f2bfd283', name: '曾晓玲', en_name: 'xiaoling' },
|
||||
'f26fe45d': { user_id: 'f26fe45d', name: 'HH', en_name: 'HH' },
|
||||
'45858f91': { user_id: '45858f91', name: 'Eva', nickname: 'zan' },
|
||||
'7f79b6de': { user_id: '7f79b6de', name: 'Issac', en_name: 'Issac' },
|
||||
'1fb2547g': { user_id: '1fb2547g', name: '王铁柱' },
|
||||
'e5997acd': { user_id: 'e5997acd', name: 'Nico', nickname: '尼克' },
|
||||
'438c3c1f': { user_id: '438c3c1f', name: 'Ivan', en_name: 'Ivan' },
|
||||
'17g8bab2': { user_id: '17g8bab2', name: 'Dodo', en_name: 'Dodo' },
|
||||
'73b45ec5': { user_id: '73b45ec5', name: '启超', en_name: 'QiChaoShi' },
|
||||
'd1978a39': { user_id: 'd1978a39', name: 'chenglin', en_name: 'chenglin' },
|
||||
'ef6fc4a7': { user_id: 'ef6fc4a7', name: '冠林', en_name: 'Green' },
|
||||
'b47fa8f2': { user_id: 'b47fa8f2', name: 'Sixian-Yu', nickname: 'sx', en_name: 'sixian' },
|
||||
'934fbf15': { user_id: '934fbf15', name: '俊晨', en_name: 'jc', nickname: 'sagiri' },
|
||||
'8c4aad87': { user_id: '8c4aad87', name: '大明', en_name: 'daming' },
|
||||
'ab87g5e1': { user_id: 'ab87g5e1', name: 'Emily Yobal', en_name: 'Emily' },
|
||||
'55fa337f': { user_id: '55fa337f', name: '景达', en_name: 'jingda' },
|
||||
'333c7cf1': { user_id: '333c7cf1', name: '刘纪源', en_name: 'Aiden', nickname: '纪源' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch all employees from Lark Contact API
|
||||
* @returns {Promise<Map<string, object>>} - Map of user_id -> employee data
|
||||
*/
|
||||
async function fetchEmployees() {
|
||||
const employees = new Map();
|
||||
let pageToken = '';
|
||||
|
||||
try {
|
||||
do {
|
||||
const params = {
|
||||
department_id: '0', // Root department = all employees
|
||||
page_size: 50,
|
||||
user_id_type: 'user_id'
|
||||
};
|
||||
if (pageToken) params.page_token = pageToken;
|
||||
|
||||
const result = await larkApi('GET', '/contact/v3/users', { params });
|
||||
|
||||
for (const user of (result.items || [])) {
|
||||
employees.set(user.user_id, {
|
||||
user_id: user.user_id,
|
||||
name: user.name,
|
||||
en_name: user.en_name,
|
||||
nickname: user.nickname,
|
||||
email: user.email,
|
||||
mobile: user.mobile,
|
||||
department_ids: user.department_ids,
|
||||
open_id: user.open_id
|
||||
});
|
||||
}
|
||||
|
||||
pageToken = result.has_more ? result.page_token : '';
|
||||
} while (pageToken);
|
||||
|
||||
return employees;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch employees from Lark API:', error.message);
|
||||
console.log('[employees] Using fallback static employee data');
|
||||
// Return fallback data
|
||||
const fallback = new Map();
|
||||
for (const [id, emp] of Object.entries(FALLBACK_EMPLOYEES)) {
|
||||
fallback.set(id, emp);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get employee cache (refreshes if stale)
|
||||
*/
|
||||
async function getEmployeeCache() {
|
||||
const now = Date.now();
|
||||
if (!employeeCache || (now - cacheTimestamp) > CACHE_TTL) {
|
||||
employeeCache = await fetchEmployees();
|
||||
cacheTimestamp = now;
|
||||
|
||||
if (employeeCache.size > 0) {
|
||||
console.log(`[employees] Loaded ${employeeCache.size} employees from Lark API`);
|
||||
}
|
||||
}
|
||||
return employeeCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build name lookup index from employee cache
|
||||
*/
|
||||
function buildNameIndex(employees) {
|
||||
const index = new Map();
|
||||
|
||||
for (const [userId, emp] of employees) {
|
||||
// Index by various name fields (case-insensitive)
|
||||
const names = [emp.name, emp.en_name, emp.nickname].filter(Boolean);
|
||||
for (const name of names) {
|
||||
index.set(name.toLowerCase(), userId);
|
||||
// Also index parts (e.g., "Wang Boyang" -> "boyang", "wang")
|
||||
for (const part of name.split(/\s+/)) {
|
||||
if (part.length > 1) {
|
||||
index.set(part.toLowerCase(), userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a name to user_id
|
||||
* @param {string} name - Employee name (case-insensitive)
|
||||
* @returns {Promise<string|null>} - user_id or null if not found
|
||||
*/
|
||||
export async function resolveNameToId(name) {
|
||||
if (!name) return null;
|
||||
|
||||
const employees = await getEmployeeCache();
|
||||
|
||||
// Check if it's already a user_id
|
||||
if (employees.has(name)) return name;
|
||||
|
||||
// Build index and lookup
|
||||
const index = buildNameIndex(employees);
|
||||
return index.get(name.toLowerCase()) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve multiple names to user_ids
|
||||
* @param {string[]} names - Array of names
|
||||
* @returns {Promise<{resolved: string[], unresolved: string[]}>}
|
||||
*/
|
||||
export async function resolveNames(names) {
|
||||
const resolved = [];
|
||||
const unresolved = [];
|
||||
|
||||
for (const name of names) {
|
||||
const userId = await resolveNameToId(name.trim());
|
||||
if (userId) {
|
||||
if (!resolved.includes(userId)) {
|
||||
resolved.push(userId);
|
||||
}
|
||||
} else {
|
||||
unresolved.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
return { resolved, unresolved };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get employee info by user_id
|
||||
* @param {string} userId
|
||||
* @returns {Promise<object|null>}
|
||||
*/
|
||||
export async function getEmployee(userId) {
|
||||
const employees = await getEmployeeCache();
|
||||
return employees.get(userId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for a user_id
|
||||
* @param {string} userId
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function getDisplayName(userId) {
|
||||
const emp = await getEmployee(userId);
|
||||
return emp ? (emp.name || emp.en_name || userId) : userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous display name (uses cached data only)
|
||||
* For use in non-async contexts - may return user_id if cache not populated
|
||||
*/
|
||||
export function getDisplayNameSync(userId) {
|
||||
if (!employeeCache) return userId;
|
||||
const emp = employeeCache.get(userId);
|
||||
return emp ? (emp.name || emp.en_name || userId) : userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure Boyang is in the attendee list
|
||||
* @param {string[]} userIds
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function ensureBoyangIncluded(userIds) {
|
||||
if (!userIds.includes(BOYANG_USER_ID)) {
|
||||
return [...userIds, BOYANG_USER_ID];
|
||||
}
|
||||
return userIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all employees (for debugging/display)
|
||||
* @returns {Promise<object[]>}
|
||||
*/
|
||||
export async function listEmployees() {
|
||||
const employees = await getEmployeeCache();
|
||||
return Array.from(employees.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Search employees by partial name match
|
||||
* @param {string} query
|
||||
* @returns {Promise<object[]>}
|
||||
*/
|
||||
export async function searchEmployees(query) {
|
||||
const employees = await getEmployeeCache();
|
||||
const results = [];
|
||||
const q = query.toLowerCase();
|
||||
|
||||
for (const emp of employees.values()) {
|
||||
const searchFields = [emp.name, emp.en_name, emp.nickname, emp.email].filter(Boolean);
|
||||
if (searchFields.some(f => f.toLowerCase().includes(q))) {
|
||||
results.push(emp);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
136
skills/lark-calendar/lib/lark-api.mjs
Normal file
136
skills/lark-calendar/lib/lark-api.mjs
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Lark (Feishu) API Wrapper
|
||||
* Handles authentication and API calls with automatic token refresh
|
||||
*/
|
||||
|
||||
import { config } from 'dotenv';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
// Load secrets
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
config({ path: join(__dirname, '../../../../.secrets.env') });
|
||||
|
||||
const APP_ID = process.env.FEISHU_APP_ID;
|
||||
const APP_SECRET = process.env.FEISHU_APP_SECRET;
|
||||
|
||||
// Use larksuite.com for international Lark
|
||||
const BASE_URL = 'https://open.larksuite.com/open-apis';
|
||||
|
||||
let accessToken = null;
|
||||
let tokenExpiry = 0;
|
||||
|
||||
/**
|
||||
* Get or refresh access token
|
||||
*/
|
||||
async function getAccessToken() {
|
||||
// Return cached token if still valid (with 5 min buffer)
|
||||
if (accessToken && Date.now() < tokenExpiry - 300000) {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
const response = await fetch(`${BASE_URL}/auth/v3/tenant_access_token/internal`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
app_id: APP_ID,
|
||||
app_secret: APP_SECRET
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.code !== 0) {
|
||||
throw new Error(`Failed to get access token: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
accessToken = `Bearer ${data.tenant_access_token}`;
|
||||
// Token expires in ~2 hours, we store expiry time
|
||||
tokenExpiry = Date.now() + (data.expire * 1000);
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make authenticated API request
|
||||
* @param {string} method - HTTP method
|
||||
* @param {string} endpoint - API endpoint (without base URL)
|
||||
* @param {object} options - { params, data }
|
||||
* @returns {object} - Response data
|
||||
*/
|
||||
export async function larkApi(method, endpoint, { params = null, data = null } = {}) {
|
||||
const token = await getAccessToken();
|
||||
|
||||
let url = `${BASE_URL}${endpoint}`;
|
||||
|
||||
// Add query params
|
||||
if (params) {
|
||||
const searchParams = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
searchParams.append(key, value);
|
||||
}
|
||||
}
|
||||
const queryString = searchParams.toString();
|
||||
if (queryString) {
|
||||
url += `?${queryString}`;
|
||||
}
|
||||
}
|
||||
|
||||
const fetchOptions = {
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': token,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
if (data && ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase())) {
|
||||
fetchOptions.body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
const result = await response.json();
|
||||
|
||||
// Check for token expiry error
|
||||
if (result.code === 99991663) {
|
||||
// Token expired, clear cache and retry once
|
||||
accessToken = null;
|
||||
tokenExpiry = 0;
|
||||
return larkApi(method, endpoint, { params, data });
|
||||
}
|
||||
|
||||
if (result.code !== 0) {
|
||||
const error = new Error(`Lark API error: ${JSON.stringify(result)}`);
|
||||
error.code = result.code;
|
||||
error.larkResponse = result;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply to a message
|
||||
* @param {string} messageId - Message ID to reply to
|
||||
* @param {object} content - Message content
|
||||
*/
|
||||
export async function replyMessage(messageId, content) {
|
||||
return larkApi('POST', `/im/v1/messages/${messageId}/reply`, { data: content });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to a chat
|
||||
* @param {string} receiveId - Chat ID or user ID
|
||||
* @param {string} receiveIdType - 'chat_id' | 'user_id' | 'open_id'
|
||||
* @param {object} content - Message content
|
||||
*/
|
||||
export async function sendMessage(receiveId, receiveIdType, content) {
|
||||
return larkApi('POST', '/im/v1/messages', {
|
||||
params: { receive_id_type: receiveIdType },
|
||||
data: {
|
||||
receive_id: receiveId,
|
||||
...content
|
||||
}
|
||||
});
|
||||
}
|
||||
187
skills/lark-calendar/lib/task.mjs
Normal file
187
skills/lark-calendar/lib/task.mjs
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Lark Task Operations
|
||||
*/
|
||||
|
||||
import { larkApi } from './lark-api.mjs';
|
||||
import { datetimeToTimestamp, timestampToDatetime, DEFAULT_TIMEZONE } from './calendar.mjs';
|
||||
|
||||
/**
|
||||
* Create a task
|
||||
* @param {object} options
|
||||
* @param {string} options.title - Task title (summary)
|
||||
* @param {string} [options.description] - Task description
|
||||
* @param {string} options.dueTime - Due time (YYYY-MM-DD HH:MM:SS)
|
||||
* @param {string[]} [options.assigneeIds] - Array of user_ids to assign
|
||||
* @param {string} [options.timezone] - IANA timezone
|
||||
* @returns {object} - Created task data
|
||||
*/
|
||||
export async function createTask({
|
||||
title,
|
||||
description = '',
|
||||
dueTime,
|
||||
assigneeIds = [],
|
||||
timezone = DEFAULT_TIMEZONE
|
||||
}) {
|
||||
const dueTimestamp = datetimeToTimestamp(dueTime, timezone);
|
||||
|
||||
// Build members array
|
||||
const members = assigneeIds.map(userId => ({
|
||||
type: 'user',
|
||||
id: userId,
|
||||
role: 'assignee'
|
||||
}));
|
||||
|
||||
const result = await larkApi('POST', '/task/v2/tasks', {
|
||||
params: { user_id_type: 'user_id' },
|
||||
data: {
|
||||
mode: 1,
|
||||
summary: title,
|
||||
description: description,
|
||||
due: {
|
||||
timestamp: String(dueTimestamp * 1000), // Task API uses milliseconds
|
||||
is_all_day: false
|
||||
},
|
||||
members: members,
|
||||
reminders: [{ relative_fire_minute: 30 }]
|
||||
}
|
||||
});
|
||||
|
||||
return result.task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a task
|
||||
* @param {object} options
|
||||
* @param {string} options.taskId - Task GUID
|
||||
* @param {string} [options.title] - Task title
|
||||
* @param {string} [options.description] - Task description
|
||||
* @param {string} [options.dueTime] - Due time (YYYY-MM-DD HH:MM:SS)
|
||||
* @param {string} [options.timezone] - IANA timezone
|
||||
* @returns {object} - Updated task data
|
||||
*/
|
||||
export async function updateTask({
|
||||
taskId,
|
||||
title,
|
||||
description,
|
||||
dueTime,
|
||||
timezone = DEFAULT_TIMEZONE
|
||||
}) {
|
||||
const updateFields = [];
|
||||
const taskUpdate = {};
|
||||
|
||||
if (title !== undefined) {
|
||||
taskUpdate.summary = title;
|
||||
updateFields.push('summary');
|
||||
}
|
||||
|
||||
if (description !== undefined) {
|
||||
taskUpdate.description = description;
|
||||
updateFields.push('description');
|
||||
}
|
||||
|
||||
if (dueTime) {
|
||||
const dueTimestamp = datetimeToTimestamp(dueTime, timezone);
|
||||
taskUpdate.due = {
|
||||
timestamp: String(dueTimestamp * 1000),
|
||||
is_all_day: false
|
||||
};
|
||||
updateFields.push('due');
|
||||
}
|
||||
|
||||
const result = await larkApi('PATCH', `/task/v2/tasks/${taskId}`, {
|
||||
params: { user_id_type: 'user_id' },
|
||||
data: {
|
||||
task: taskUpdate,
|
||||
update_fields: updateFields
|
||||
}
|
||||
});
|
||||
|
||||
return result.task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a task
|
||||
* @param {string} taskId - Task GUID
|
||||
* @returns {boolean} - Success
|
||||
*/
|
||||
export async function deleteTask(taskId) {
|
||||
await larkApi('DELETE', `/task/v2/tasks/${taskId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add members to a task
|
||||
* @param {string} taskId - Task GUID
|
||||
* @param {string[]} userIds - Array of user_ids
|
||||
* @returns {object} - Result
|
||||
*/
|
||||
export async function addTaskMembers(taskId, userIds) {
|
||||
const members = userIds.map(userId => ({
|
||||
type: 'user',
|
||||
id: userId,
|
||||
role: 'assignee'
|
||||
}));
|
||||
|
||||
return larkApi('POST', `/task/v2/tasks/${taskId}/add_members`, {
|
||||
params: { user_id_type: 'user_id' },
|
||||
data: { members }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove members from a task
|
||||
* @param {string} taskId - Task GUID
|
||||
* @param {string[]} userIds - Array of user_ids
|
||||
* @returns {object} - Result
|
||||
*/
|
||||
export async function removeTaskMembers(taskId, userIds) {
|
||||
const members = userIds.map(userId => ({
|
||||
type: 'user',
|
||||
id: userId,
|
||||
role: 'assignee'
|
||||
}));
|
||||
|
||||
return larkApi('POST', `/task/v2/tasks/${taskId}/remove_members`, {
|
||||
params: { user_id_type: 'user_id' },
|
||||
data: { members }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a task by ID
|
||||
* @param {string} taskId - Task GUID
|
||||
* @returns {object} - Task data
|
||||
*/
|
||||
export async function getTask(taskId) {
|
||||
const result = await larkApi('GET', `/task/v2/tasks/${taskId}`, {
|
||||
params: { user_id_type: 'user_id' }
|
||||
});
|
||||
return result.task;
|
||||
}
|
||||
|
||||
/**
|
||||
* List tasks (with pagination)
|
||||
* @param {object} options
|
||||
* @param {number} [options.pageSize] - Number of tasks per page
|
||||
* @param {string} [options.pageToken] - Pagination token
|
||||
* @returns {object} - { items, page_token, has_more }
|
||||
*/
|
||||
export async function listTasks({ pageSize = 50, pageToken = '' } = {}) {
|
||||
const params = { page_size: pageSize, user_id_type: 'user_id' };
|
||||
if (pageToken) params.page_token = pageToken;
|
||||
|
||||
return larkApi('GET', '/task/v2/tasks', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a task
|
||||
* @param {string} taskId - Task GUID
|
||||
* @returns {object} - Updated task
|
||||
*/
|
||||
export async function completeTask(taskId) {
|
||||
return updateTask({
|
||||
taskId,
|
||||
// Mark as completed by setting completed_at
|
||||
// Note: Actual completion might need different API call
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user