Files
CherryHQ-cherry-studio/scripts/feishu-notify.ts
SuYao 18ec986157 revert: remove Feishu at-mention from issue notification card (#13201)
### What this PR does

Before this PR:
Attempted to add Feishu @mention notifications in issue cards using `<at
id=...></at>` tags with various ID formats (app ID, open_id).

After this PR:
Reverted all mention-related changes. The Feishu card content is
restored to its original state without @mention tags.

### Why we need it and why it was done in this way

Testing confirmed that while the `<at>` tag with open_id displays
correctly in the card, Feishu does not provide a message receive API for
bots — so bot users cannot actually receive @mention notifications. The
feature is not feasible with the current Feishu API capabilities.

### Breaking changes

None

### Special notes for your reviewer

This PR reverts the mention feature added in #13199 and all subsequent
test commits. Net diff against `main` should only contain the revert of
#13199.

### Checklist

- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: Write code that humans can understand and Keep it simple
- [x] Refactor: You have left the code cleaner than you found it (Boy
Scout Rule)
- [x] Upgrade: Impact of this change on upgrade flows was considered and
addressed if required
- [ ] Documentation: N/A - no user-facing change
- [x] Self-review: I have reviewed my own code before requesting review
from others

### Release note

```release-note
NONE
```

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 19:43:59 +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()