cherry-studio/scripts/feishu-notify.ts
Phantom 43a48a4a38
feat(scripts): migrate feishu-notify to TypeScript CLI tool with subcommands (#12371)
* build: add commander package as dependency

* refactor(scripts): migrate feishu-notify to TypeScript with CLI interface

- Convert JavaScript implementation to TypeScript with proper type definitions
- Add CLI interface using commander for better usability
- Improve error handling and input validation
- Add version management and subcommand support

* ci(workflows): update feishu notification command and add pnpm install step

Update the feishu notification command to use CLI tool with proper arguments instead of direct node script execution
Add pnpm install step to ensure dependencies are available before running the workflow

* docs: add feishu notification script documentation

Add Chinese and English documentation for the feishu-notify.ts CLI tool

* feat(notify): add generic send command to feishu-notify

Add a new 'send' subcommand to send simple notifications to Feishu with customizable title, description and header color. This provides a more flexible way to send notifications without being tied to specific business logic like the existing 'issue' command.

The implementation includes:
- New send command handler and options interface
- Simple card creation function
- Zod schema for header color validation
- Documentation updates in both Chinese and English
2026-01-08 16:55:46 +08:00

422 lines
10 KiB
TypeScript

#!/usr/bin/env npx tsx
/**
* @fileoverview Feishu (Lark) Webhook Notification CLI Tool
* @description Sends notifications to Feishu with signature verification.
* Supports subcommands for different notification types.
* @module feishu-notify
* @example
* // Send GitHub issue notification
* pnpm tsx feishu-notify.ts issue -u "https://..." -n "123" -t "Title" -m "Summary"
*
* // Using environment variables for credentials
* FEISHU_WEBHOOK_URL="..." FEISHU_WEBHOOK_SECRET="..." pnpm tsx feishu-notify.ts issue ...
*/
import { Command } from 'commander'
import crypto from 'crypto'
import dotenv from 'dotenv'
import https from 'https'
import * as z from 'zod'
// Load environment variables from .env file
dotenv.config()
/** CLI tool version */
const VERSION = '1.0.0'
/** GitHub issue data structure */
interface IssueData {
/** GitHub issue URL */
issueUrl: string
/** Issue number */
issueNumber: string
/** Issue title */
issueTitle: string
/** Issue summary/description */
issueSummary: string
/** Issue author username */
issueAuthor: string
/** Issue labels */
labels: string[]
}
/** Feishu card text element */
interface FeishuTextElement {
tag: 'div'
text: {
tag: 'lark_md'
content: string
}
}
/** Feishu card horizontal rule element */
interface FeishuHrElement {
tag: 'hr'
}
/** Feishu card action button */
interface FeishuActionElement {
tag: 'action'
actions: Array<{
tag: 'button'
text: {
tag: 'plain_text'
content: string
}
type: 'primary' | 'default'
url: string
}>
}
/** Feishu card element union type */
type FeishuCardElement = FeishuTextElement | FeishuHrElement | FeishuActionElement
/** Zod schema for Feishu header color template */
const FeishuHeaderTemplateSchema = z.enum([
'blue',
'wathet',
'turquoise',
'green',
'yellow',
'orange',
'red',
'carmine',
'violet',
'purple',
'indigo',
'grey',
'default'
])
/** Feishu card header color template (inferred from schema) */
type FeishuHeaderTemplate = z.infer<typeof FeishuHeaderTemplateSchema>
/** Feishu interactive card structure */
interface FeishuCard {
elements: FeishuCardElement[]
header: {
template: FeishuHeaderTemplate
title: {
tag: 'plain_text'
content: string
}
}
}
/** Feishu webhook request payload */
interface FeishuPayload {
timestamp: string
sign: string
msg_type: 'interactive'
card: FeishuCard
}
/** Issue subcommand options */
interface IssueOptions {
url: string
number: string
title: string
summary: string
author?: string
labels?: string
}
/** Send subcommand options */
interface SendOptions {
title: string
description: string
color?: string
}
/**
* Generate Feishu webhook signature using HMAC-SHA256
* @param secret - Feishu webhook secret
* @param timestamp - Unix timestamp in seconds
* @returns Base64 encoded signature
*/
function generateSignature(secret: string, timestamp: number): string {
const stringToSign = `${timestamp}\n${secret}`
const hmac = crypto.createHmac('sha256', stringToSign)
return hmac.digest('base64')
}
/**
* Send message to Feishu webhook
* @param webhookUrl - Feishu webhook URL
* @param secret - Feishu webhook secret
* @param content - Feishu card message content
* @returns Resolves when message is sent successfully
* @throws When Feishu API returns non-2xx status code or network error occurs
*/
function sendToFeishu(webhookUrl: string, secret: string, content: FeishuCard): Promise<void> {
return new Promise((resolve, reject) => {
const timestamp = Math.floor(Date.now() / 1000)
const sign = generateSignature(secret, timestamp)
const payload: FeishuPayload = {
timestamp: timestamp.toString(),
sign,
msg_type: 'interactive',
card: content
}
const payloadStr = JSON.stringify(payload)
const url = new URL(webhookUrl)
const options: https.RequestOptions = {
hostname: url.hostname,
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payloadStr)
}
}
const req = https.request(options, (res) => {
let data = ''
res.on('data', (chunk: Buffer) => {
data += chunk.toString()
})
res.on('end', () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
console.log('Successfully sent to Feishu:', data)
resolve()
} else {
reject(new Error(`Feishu API error: ${res.statusCode} - ${data}`))
}
})
})
req.on('error', (error: Error) => {
reject(error)
})
req.write(payloadStr)
req.end()
})
}
/**
* Create Feishu card message from issue data
* @param issueData - GitHub issue data
* @returns Feishu card content
*/
function createIssueCard(issueData: IssueData): FeishuCard {
const { issueUrl, issueNumber, issueTitle, issueSummary, issueAuthor, labels } = issueData
const elements: FeishuCardElement[] = [
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**Author:** ${issueAuthor}`
}
}
]
if (labels.length > 0) {
elements.push({
tag: 'div',
text: {
tag: 'lark_md',
content: `**Labels:** ${labels.join(', ')}`
}
})
}
elements.push(
{ tag: 'hr' },
{
tag: 'div',
text: {
tag: 'lark_md',
content: `**Summary:**\n${issueSummary}`
}
},
{ tag: 'hr' },
{
tag: 'action',
actions: [
{
tag: 'button',
text: {
tag: 'plain_text',
content: 'View Issue'
},
type: 'primary',
url: issueUrl
}
]
}
)
return {
elements,
header: {
template: 'blue',
title: {
tag: 'plain_text',
content: `#${issueNumber} - ${issueTitle}`
}
}
}
}
/**
* Create a simple Feishu card message
* @param title - Card title
* @param description - Card description content
* @param color - Header color template (default: 'turquoise')
* @returns Feishu card content
*/
function createSimpleCard(title: string, description: string, color: FeishuHeaderTemplate = 'turquoise'): FeishuCard {
return {
elements: [
{
tag: 'div',
text: {
tag: 'lark_md',
content: description
}
}
],
header: {
template: color,
title: {
tag: 'plain_text',
content: title
}
}
}
}
/**
* Get Feishu credentials from environment variables
*/
function getCredentials(): { webhookUrl: string; secret: string } {
const webhookUrl = process.env.FEISHU_WEBHOOK_URL
const secret = process.env.FEISHU_WEBHOOK_SECRET
if (!webhookUrl) {
console.error('Error: FEISHU_WEBHOOK_URL environment variable is required')
process.exit(1)
}
if (!secret) {
console.error('Error: FEISHU_WEBHOOK_SECRET environment variable is required')
process.exit(1)
}
return { webhookUrl, secret }
}
/**
* Handle send subcommand
*/
async function handleSendCommand(options: SendOptions): Promise<void> {
const { webhookUrl, secret } = getCredentials()
const { title, description, color = 'turquoise' } = options
// Validate color parameter
const colorValidation = FeishuHeaderTemplateSchema.safeParse(color)
if (!colorValidation.success) {
console.error(`Error: Invalid color "${color}". Valid colors: ${FeishuHeaderTemplateSchema.options.join(', ')}`)
process.exit(1)
}
const card = createSimpleCard(title, description, colorValidation.data)
console.log('Sending notification to Feishu...')
console.log(`Title: ${title}`)
await sendToFeishu(webhookUrl, secret, card)
console.log('Notification sent successfully!')
}
/**
* Handle issue subcommand
*/
async function handleIssueCommand(options: IssueOptions): Promise<void> {
const { webhookUrl, secret } = getCredentials()
const { url, number, title, summary, author = 'Unknown', labels: labelsStr = '' } = options
if (!url || !number || !title || !summary) {
console.error('Error: --url, --number, --title, and --summary are required')
process.exit(1)
}
const labels = labelsStr
? labelsStr
.split(',')
.map((l) => l.trim())
.filter(Boolean)
: []
const issueData: IssueData = {
issueUrl: url,
issueNumber: number,
issueTitle: title,
issueSummary: summary,
issueAuthor: author,
labels
}
const card = createIssueCard(issueData)
console.log('Sending notification to Feishu...')
console.log(`Issue #${number}: ${title}`)
await sendToFeishu(webhookUrl, secret, card)
console.log('Notification sent successfully!')
}
// Configure CLI
const program = new Command()
program.name('feishu-notify').description('Send notifications to Feishu webhook').version(VERSION)
// Send subcommand (generic)
program
.command('send')
.description('Send a simple notification to Feishu')
.requiredOption('-t, --title <title>', 'Card title')
.requiredOption('-d, --description <description>', 'Card description (supports markdown)')
.option(
'-c, --color <color>',
`Header color template (default: turquoise). Options: ${FeishuHeaderTemplateSchema.options.join(', ')}`,
'turquoise'
)
.action(async (options: SendOptions) => {
try {
await handleSendCommand(options)
} catch (error) {
console.error('Error:', error instanceof Error ? error.message : error)
process.exit(1)
}
})
// Issue subcommand
program
.command('issue')
.description('Send GitHub issue notification to Feishu')
.requiredOption('-u, --url <url>', 'GitHub issue URL')
.requiredOption('-n, --number <number>', 'Issue number')
.requiredOption('-t, --title <title>', 'Issue title')
.requiredOption('-m, --summary <summary>', 'Issue summary')
.option('-a, --author <author>', 'Issue author', 'Unknown')
.option('-l, --labels <labels>', 'Issue labels, comma-separated')
.action(async (options: IssueOptions) => {
try {
await handleIssueCommand(options)
} catch (error) {
console.error('Error:', error instanceof Error ? error.message : error)
process.exit(1)
}
})
program.parse()