394 lines
9.6 KiB
TypeScript
394 lines
9.6 KiB
TypeScript
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<string> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string | null> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string | null> {
|
|
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<RichMenuResponse[]> {
|
|
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<RichMenuResponse | null> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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 };
|