import { readFile } from "node:fs/promises"; import { messagingApi } from "@line/bot-sdk"; import { loadConfig } from "../config/config.js"; import { logVerbose } from "../globals.js"; import { resolveLineAccount } from "./accounts.js"; import { datetimePickerAction, messageAction, postbackAction, uriAction } from "./actions.js"; import { resolveLineChannelAccessToken } from "./channel-access-token.js"; type RichMenuRequest = messagingApi.RichMenuRequest; type RichMenuResponse = messagingApi.RichMenuResponse; type RichMenuArea = messagingApi.RichMenuArea; type Action = messagingApi.Action; const USER_BATCH_SIZE = 500; export interface RichMenuSize { width: 2500; height: 1686 | 843; } export interface RichMenuAreaRequest { bounds: { x: number; y: number; width: number; height: number; }; action: Action; } export interface CreateRichMenuParams { size: RichMenuSize; selected?: boolean; name: string; chatBarText: string; areas: RichMenuAreaRequest[]; } interface RichMenuOpts { channelAccessToken?: string; accountId?: string; verbose?: boolean; } function getClient(opts: RichMenuOpts = {}): messagingApi.MessagingApiClient { const cfg = loadConfig(); const account = resolveLineAccount({ cfg, accountId: opts.accountId, }); const token = resolveLineChannelAccessToken(opts.channelAccessToken, account); return new messagingApi.MessagingApiClient({ channelAccessToken: token, }); } function getBlobClient(opts: RichMenuOpts = {}): messagingApi.MessagingApiBlobClient { const cfg = loadConfig(); const account = resolveLineAccount({ cfg, accountId: opts.accountId, }); const token = resolveLineChannelAccessToken(opts.channelAccessToken, account); return new messagingApi.MessagingApiBlobClient({ channelAccessToken: token, }); } function chunkUserIds(userIds: string[]): string[][] { const batches: string[][] = []; for (let i = 0; i < userIds.length; i += USER_BATCH_SIZE) { batches.push(userIds.slice(i, i + USER_BATCH_SIZE)); } return batches; } /** * Create a new rich menu * @returns The rich menu ID */ export async function createRichMenu( menu: CreateRichMenuParams, opts: RichMenuOpts = {}, ): Promise { const client = getClient(opts); const richMenuRequest: RichMenuRequest = { size: menu.size, selected: menu.selected ?? false, name: menu.name.slice(0, 300), // LINE limit chatBarText: menu.chatBarText.slice(0, 14), // LINE limit areas: menu.areas as RichMenuArea[], }; const response = await client.createRichMenu(richMenuRequest); if (opts.verbose) { logVerbose(`line: created rich menu ${response.richMenuId}`); } return response.richMenuId; } /** * Upload an image for a rich menu * Image requirements: * - Format: JPEG or PNG * - Size: Must match the rich menu size (2500x1686 or 2500x843) * - Max file size: 1MB */ export async function uploadRichMenuImage( richMenuId: string, imagePath: string, opts: RichMenuOpts = {}, ): Promise { const blobClient = getBlobClient(opts); const imageData = await readFile(imagePath); const contentType = imagePath.toLowerCase().endsWith(".png") ? "image/png" : "image/jpeg"; await blobClient.setRichMenuImage(richMenuId, new Blob([imageData], { type: contentType })); if (opts.verbose) { logVerbose(`line: uploaded image to rich menu ${richMenuId}`); } } /** * Set the default rich menu for all users */ export async function setDefaultRichMenu( richMenuId: string, opts: RichMenuOpts = {}, ): Promise { const client = getClient(opts); await client.setDefaultRichMenu(richMenuId); if (opts.verbose) { logVerbose(`line: set default rich menu to ${richMenuId}`); } } /** * Cancel the default rich menu */ export async function cancelDefaultRichMenu(opts: RichMenuOpts = {}): Promise { const client = getClient(opts); await client.cancelDefaultRichMenu(); if (opts.verbose) { logVerbose(`line: cancelled default rich menu`); } } /** * Get the default rich menu ID */ export async function getDefaultRichMenuId(opts: RichMenuOpts = {}): Promise { const client = getClient(opts); try { const response = await client.getDefaultRichMenuId(); return response.richMenuId ?? null; } catch { return null; } } /** * Link a rich menu to a specific user */ export async function linkRichMenuToUser( userId: string, richMenuId: string, opts: RichMenuOpts = {}, ): Promise { const client = getClient(opts); await client.linkRichMenuIdToUser(userId, richMenuId); if (opts.verbose) { logVerbose(`line: linked rich menu ${richMenuId} to user ${userId}`); } } /** * Link a rich menu to multiple users (up to 500) */ export async function linkRichMenuToUsers( userIds: string[], richMenuId: string, opts: RichMenuOpts = {}, ): Promise { const client = getClient(opts); for (const batch of chunkUserIds(userIds)) { await client.linkRichMenuIdToUsers({ richMenuId, userIds: batch, }); } if (opts.verbose) { logVerbose(`line: linked rich menu ${richMenuId} to ${userIds.length} users`); } } /** * Unlink a rich menu from a specific user */ export async function unlinkRichMenuFromUser( userId: string, opts: RichMenuOpts = {}, ): Promise { const client = getClient(opts); await client.unlinkRichMenuIdFromUser(userId); if (opts.verbose) { logVerbose(`line: unlinked rich menu from user ${userId}`); } } /** * Unlink rich menus from multiple users (up to 500) */ export async function unlinkRichMenuFromUsers( userIds: string[], opts: RichMenuOpts = {}, ): Promise { const client = getClient(opts); for (const batch of chunkUserIds(userIds)) { await client.unlinkRichMenuIdFromUsers({ userIds: batch, }); } if (opts.verbose) { logVerbose(`line: unlinked rich menu from ${userIds.length} users`); } } /** * Get the rich menu linked to a specific user */ export async function getRichMenuIdOfUser( userId: string, opts: RichMenuOpts = {}, ): Promise { const client = getClient(opts); try { const response = await client.getRichMenuIdOfUser(userId); return response.richMenuId ?? null; } catch { return null; } } /** * Get a list of all rich menus */ export async function getRichMenuList(opts: RichMenuOpts = {}): Promise { const client = getClient(opts); const response = await client.getRichMenuList(); return response.richmenus ?? []; } /** * Get a specific rich menu by ID */ export async function getRichMenu( richMenuId: string, opts: RichMenuOpts = {}, ): Promise { const client = getClient(opts); try { return await client.getRichMenu(richMenuId); } catch { return null; } } /** * Delete a rich menu */ export async function deleteRichMenu(richMenuId: string, opts: RichMenuOpts = {}): Promise { const client = getClient(opts); await client.deleteRichMenu(richMenuId); if (opts.verbose) { logVerbose(`line: deleted rich menu ${richMenuId}`); } } /** * Create a rich menu alias */ export async function createRichMenuAlias( richMenuId: string, aliasId: string, opts: RichMenuOpts = {}, ): Promise { const client = getClient(opts); await client.createRichMenuAlias({ richMenuId, richMenuAliasId: aliasId, }); if (opts.verbose) { logVerbose(`line: created alias ${aliasId} for rich menu ${richMenuId}`); } } /** * Delete a rich menu alias */ export async function deleteRichMenuAlias(aliasId: string, opts: RichMenuOpts = {}): Promise { const client = getClient(opts); await client.deleteRichMenuAlias(aliasId); if (opts.verbose) { logVerbose(`line: deleted alias ${aliasId}`); } } // ============================================================================ // Default Menu Template Helpers // ============================================================================ /** * Create a standard 2x3 grid layout for rich menu areas * Returns 6 areas in a 2-row, 3-column layout */ export function createGridLayout( height: 1686 | 843, actions: [Action, Action, Action, Action, Action, Action], ): RichMenuAreaRequest[] { const colWidth = Math.floor(2500 / 3); const rowHeight = Math.floor(height / 2); return [ // Top row { bounds: { x: 0, y: 0, width: colWidth, height: rowHeight }, action: actions[0] }, { bounds: { x: colWidth, y: 0, width: colWidth, height: rowHeight }, action: actions[1] }, { bounds: { x: colWidth * 2, y: 0, width: colWidth, height: rowHeight }, action: actions[2] }, // Bottom row { bounds: { x: 0, y: rowHeight, width: colWidth, height: rowHeight }, action: actions[3] }, { bounds: { x: colWidth, y: rowHeight, width: colWidth, height: rowHeight }, action: actions[4], }, { bounds: { x: colWidth * 2, y: rowHeight, width: colWidth, height: rowHeight }, action: actions[5], }, ]; } export { datetimePickerAction, messageAction, postbackAction, uriAction }; /** * Create a default help/status/settings menu * This is a convenience function to quickly set up a standard menu */ export function createDefaultMenuConfig(): CreateRichMenuParams { return { size: { width: 2500, height: 843 }, selected: false, name: "Default Menu", chatBarText: "Menu", areas: createGridLayout(843, [ messageAction("Help", "/help"), messageAction("Status", "/status"), messageAction("Settings", "/settings"), messageAction("About", "/about"), messageAction("Feedback", "/feedback"), messageAction("Contact", "/contact"), ]), }; } // Re-export types export type { RichMenuRequest, RichMenuResponse, RichMenuArea, Action };