mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-09 01:04:44 +08:00
### 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>
422 lines
10 KiB
TypeScript
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()
|