feat: improve Feishu messaging — markdown rendering, card styling, reaction lifecycle
outbound.ts:
- Switch from msg_type 'text' to 'post' with {tag: 'md'} for markdown rendering
- Add optimizeMarkdown(): heading demotion, table spacing, code block padding, image key stripping
- Redesign permission card: blue header with lock icon, notation-sized timeout hint,
column_set button layout (Schema V2 compatible, 'action' tag deprecated)
- Add addReaction/removeReaction for emoji acknowledgment lifecycle
index.ts:
- Implement onMessageStart: add 'OnIt' emoji on message receipt
- Implement onMessageEnd: remove reaction after response sent
- Track lastMessageIdByChat for reaction targeting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
9ca9bce788
commit
647fa041d1
2 files changed
+265
-76
No files matched your search
@@ -11,7 +11,7 @@ import type { FeishuConfig } from './types';
|
|||||||
import { loadFeishuConfig, validateFeishuConfig } from './config';
|
import { loadFeishuConfig, validateFeishuConfig } from './config';
|
||||||
import { FeishuGateway } from './gateway';
|
import { FeishuGateway } from './gateway';
|
||||||
import { parseInboundMessage } from './inbound';
|
import { parseInboundMessage } from './inbound';
|
||||||
import { sendMessage } from './outbound';
|
import { sendMessage, addReaction, removeReaction } from './outbound';
|
||||||
import { isUserAuthorized } from './policy';
|
import { isUserAuthorized } from './policy';
|
||||||
import { createCardStreamController } from './card-controller';
|
import { createCardStreamController } from './card-controller';
|
||||||
|
|
||||||
@@ -25,6 +25,10 @@ export class FeishuChannelPlugin implements ChannelPlugin<FeishuConfig> {
|
|||||||
private gateway: FeishuGateway | null = null;
|
private gateway: FeishuGateway | null = null;
|
||||||
private messageQueue: InboundMessage[] = [];
|
private messageQueue: InboundMessage[] = [];
|
||||||
private waitResolve: ((msg: InboundMessage | null) => void) | null = null;
|
private waitResolve: ((msg: InboundMessage | null) => void) | null = null;
|
||||||
|
/** Track last received messageId per chatId for reaction acknowledgment. */
|
||||||
|
private lastMessageIdByChat = new Map<string, string>();
|
||||||
|
/** Track active reaction IDs per chatId so we can remove them on completion. */
|
||||||
|
private activeReactions = new Map<string, { messageId: string; reactionId: string }>();
|
||||||
|
|
||||||
loadConfig(): FeishuConfig | null {
|
loadConfig(): FeishuConfig | null {
|
||||||
this.config = loadFeishuConfig();
|
this.config = loadFeishuConfig();
|
||||||
@@ -163,6 +167,10 @@ export class FeishuChannelPlugin implements ChannelPlugin<FeishuConfig> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private enqueueMessage(msg: InboundMessage): void {
|
private enqueueMessage(msg: InboundMessage): void {
|
||||||
|
// Track messageId for reaction acknowledgment (skip callback messages)
|
||||||
|
if (msg.messageId && !msg.callbackData) {
|
||||||
|
this.lastMessageIdByChat.set(msg.address.chatId, msg.messageId);
|
||||||
|
}
|
||||||
if (this.waitResolve) {
|
if (this.waitResolve) {
|
||||||
const resolve = this.waitResolve;
|
const resolve = this.waitResolve;
|
||||||
this.waitResolve = null;
|
this.waitResolve = null;
|
||||||
@@ -192,6 +200,28 @@ export class FeishuChannelPlugin implements ChannelPlugin<FeishuConfig> {
|
|||||||
return isUserAuthorized(this.config, userId, chatId);
|
return isUserAuthorized(this.config, userId, chatId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Add emoji reaction to acknowledge message receipt. */
|
||||||
|
onMessageStart(chatId: string): void {
|
||||||
|
const client = this.gateway?.getRestClient();
|
||||||
|
const messageId = this.lastMessageIdByChat.get(chatId);
|
||||||
|
if (!client || !messageId) return;
|
||||||
|
// Fire-and-forget — don't block message processing
|
||||||
|
addReaction(client, messageId, 'OnIt').then((reactionId) => {
|
||||||
|
if (reactionId) {
|
||||||
|
this.activeReactions.set(chatId, { messageId, reactionId });
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove the "processing" reaction after response is sent. */
|
||||||
|
onMessageEnd(chatId: string): void {
|
||||||
|
const client = this.gateway?.getRestClient();
|
||||||
|
const reaction = this.activeReactions.get(chatId);
|
||||||
|
if (!client || !reaction) return;
|
||||||
|
this.activeReactions.delete(chatId);
|
||||||
|
removeReaction(client, reaction.messageId, reaction.reactionId).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
getCardStreamController(): CardStreamController | null {
|
getCardStreamController(): CardStreamController | null {
|
||||||
const client = this.gateway?.getRestClient();
|
const client = this.gateway?.getRestClient();
|
||||||
if (!client || !this.config?.cardStreamConfig) return null;
|
if (!client || !this.config?.cardStreamConfig) return null;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Feishu outbound message rendering and sending.
|
* Feishu outbound message rendering and sending.
|
||||||
*
|
*
|
||||||
* Missing capabilities compared to OpenClaw's ChannelMessageActionAdapter:
|
* Text messages use `post` format with `md` tag for markdown rendering.
|
||||||
* - No react/unreact support (add/remove emoji reactions)
|
* Card messages (buttons, permissions) use Schema V2 interactive cards.
|
||||||
* - No message delete/unsend support
|
*
|
||||||
* - No user identity (user_access_token) sending — bot identity only
|
* Markdown is optimized for Feishu: heading demotion, table spacing,
|
||||||
|
* code block padding, and invalid image key stripping.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type * as lark from '@larksuiteoapi/node-sdk';
|
import type * as lark from '@larksuiteoapi/node-sdk';
|
||||||
@@ -12,66 +13,156 @@ import type { OutboundMessage, SendResult } from '../../bridge/types';
|
|||||||
|
|
||||||
const LOG_TAG = '[feishu/outbound]';
|
const LOG_TAG = '[feishu/outbound]';
|
||||||
|
|
||||||
|
// ─── Markdown optimization for Feishu ────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a message to Feishu using bot identity (app_access_token).
|
* Optimize markdown for Feishu rendering compatibility.
|
||||||
*
|
*
|
||||||
* If the message contains inlineButtons, it is rendered as an interactive card
|
* Based on OpenClaw's markdown-style.ts:
|
||||||
* (Schema V2 with column_set + button layout). Otherwise it's sent as plain text.
|
* - Demote headings: H1 → H4, H2-H6 → H5 (Feishu renders H1-H3 too large)
|
||||||
|
* - Add spacing around tables with <br> tags
|
||||||
|
* - Pad code blocks with <br> for visual separation
|
||||||
|
* - Strip invalid image keys (prevent CardKit error 200570)
|
||||||
|
* - Compress excessive newlines (3+ → 2)
|
||||||
|
*/
|
||||||
|
function optimizeMarkdown(text: string): string {
|
||||||
|
try {
|
||||||
|
return _optimizeMarkdown(text);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _optimizeMarkdown(text: string): string {
|
||||||
|
// 1. Extract code blocks — protect from transformation
|
||||||
|
const MARK = '___CB_';
|
||||||
|
const codeBlocks: string[] = [];
|
||||||
|
let r = text.replace(/```[\s\S]*?```/g, (m) => {
|
||||||
|
return `${MARK}${codeBlocks.push(m) - 1}___`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Heading demotion (only if H1-H3 exist)
|
||||||
|
const hasLargeHeadings = /^#{1,3} /m.test(r);
|
||||||
|
if (hasLargeHeadings) {
|
||||||
|
r = r.replace(/^#{2,6} (.+)$/gm, '##### $1'); // H2-H6 → H5
|
||||||
|
r = r.replace(/^# (.+)$/gm, '#### $1'); // H1 → H4
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Spacing between consecutive headings
|
||||||
|
r = r.replace(/^(#{4,5} .+)\n{1,2}(#{4,5} )/gm, '$1\n<br>\n$2');
|
||||||
|
|
||||||
|
// 4. Table spacing — add <br> before/after table blocks
|
||||||
|
r = r.replace(/^([^|\n].*)\n(\|.+\|)/gm, '$1\n\n$2');
|
||||||
|
r = r.replace(/\n\n((?:\|.+\|[^\S\n]*\n?)+)/g, '\n\n<br>\n\n$1');
|
||||||
|
r = r.replace(/((?:^\|.+\|[^\S\n]*\n?)+)/gm, '$1\n<br>\n');
|
||||||
|
|
||||||
|
// 5. Restore code blocks with <br> padding
|
||||||
|
codeBlocks.forEach((block, i) => {
|
||||||
|
r = r.replace(`${MARK}${i}___`, `\n<br>\n${block}\n<br>\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Strip invalid image keys (only allow img_xxx and http(s) URLs)
|
||||||
|
if (r.includes('![')) {
|
||||||
|
r = r.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_full, _alt, value) => {
|
||||||
|
if (value.startsWith('img_') || value.startsWith('http://') || value.startsWith('https://')) {
|
||||||
|
return _full;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Compress excessive newlines
|
||||||
|
r = r.replace(/\n{3,}/g, '\n\n');
|
||||||
|
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert HTML-formatted text (from permission-broker) to Feishu markdown.
|
||||||
|
*/
|
||||||
|
function htmlToFeishuMarkdown(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/<b>(.*?)<\/b>/gi, '**$1**')
|
||||||
|
.replace(/<code>(.*?)<\/code>/gi, '`$1`')
|
||||||
|
.replace(/<pre>([\s\S]*?)<\/pre>/gi, '```\n$1\n```')
|
||||||
|
.replace(/<\/?[^>]+>/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Message sending ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to Feishu.
|
||||||
*
|
*
|
||||||
* OpenClaw supports both bot and user identity via its ChannelMessageActionAdapter,
|
* - With inlineButtons → interactive card (Schema V2)
|
||||||
* allowing messages to be sent as the authenticated user when user_access_token is available.
|
* - Without → post format with md tag (supports markdown rendering)
|
||||||
*
|
|
||||||
* This handles static message delivery — card streaming is handled upstream by bridge-manager.
|
|
||||||
*/
|
*/
|
||||||
export async function sendMessage(
|
export async function sendMessage(
|
||||||
client: lark.Client,
|
client: lark.Client,
|
||||||
message: OutboundMessage,
|
message: OutboundMessage,
|
||||||
): Promise<SendResult> {
|
): Promise<SendResult> {
|
||||||
try {
|
try {
|
||||||
// Extract real chat ID (strip thread prefix if present)
|
|
||||||
const chatId = message.address.chatId.split(':thread:')[0];
|
const chatId = message.address.chatId.split(':thread:')[0];
|
||||||
const replyId = message.replyToMessageId;
|
const replyId = message.replyToMessageId;
|
||||||
|
|
||||||
// If message has inline buttons, send as interactive card
|
// Interactive card for messages with buttons
|
||||||
if (message.inlineButtons && message.inlineButtons.length > 0) {
|
if (message.inlineButtons && message.inlineButtons.length > 0) {
|
||||||
return sendAsInteractiveCard(client, chatId, message.text, message.inlineButtons, replyId);
|
return sendAsInteractiveCard(client, chatId, message.text, message.inlineButtons, replyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = JSON.stringify({ text: message.text });
|
// Post format with md tag for markdown support
|
||||||
|
return sendAsPost(client, chatId, message.text, message.parseMode, replyId);
|
||||||
let resp: any;
|
|
||||||
if (replyId) {
|
|
||||||
resp = await client.im.message.reply({
|
|
||||||
path: { message_id: replyId },
|
|
||||||
data: {
|
|
||||||
content,
|
|
||||||
msg_type: 'text',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
resp = await client.im.message.create({
|
|
||||||
params: { receive_id_type: 'chat_id' },
|
|
||||||
data: {
|
|
||||||
receive_id: chatId,
|
|
||||||
content,
|
|
||||||
msg_type: 'text',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const msgId = resp?.data?.message_id || '';
|
|
||||||
return { ok: true, messageId: msgId };
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(LOG_TAG, 'Send failed:', err?.message || err);
|
console.error(LOG_TAG, 'Send failed:', err?.message || err);
|
||||||
return { ok: false, error: err?.message || 'Unknown error' };
|
return { ok: false, error: err?.message || 'Unknown error' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message using post format with md tag.
|
||||||
|
* This enables markdown rendering in Feishu (bold, code, lists, tables, etc).
|
||||||
|
*/
|
||||||
|
async function sendAsPost(
|
||||||
|
client: lark.Client,
|
||||||
|
chatId: string,
|
||||||
|
text: string,
|
||||||
|
parseMode?: string,
|
||||||
|
replyToMessageId?: string,
|
||||||
|
): Promise<SendResult> {
|
||||||
|
// Convert HTML to markdown if needed, then optimize for Feishu
|
||||||
|
let mdText = parseMode === 'HTML' ? htmlToFeishuMarkdown(text) : text;
|
||||||
|
mdText = optimizeMarkdown(mdText);
|
||||||
|
|
||||||
|
const content = JSON.stringify({
|
||||||
|
zh_cn: {
|
||||||
|
content: [[{ tag: 'md', text: mdText }]],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let resp: any;
|
||||||
|
if (replyToMessageId) {
|
||||||
|
resp = await client.im.message.reply({
|
||||||
|
path: { message_id: replyToMessageId },
|
||||||
|
data: { content, msg_type: 'post' },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resp = await client.im.message.create({
|
||||||
|
params: { receive_id_type: 'chat_id' },
|
||||||
|
data: { receive_id: chatId, content, msg_type: 'post' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const msgId = resp?.data?.message_id || '';
|
||||||
|
return { ok: true, messageId: msgId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Interactive cards ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build and send a Feishu interactive card with buttons.
|
* Build and send a Feishu interactive card with buttons.
|
||||||
*
|
*
|
||||||
* Uses Schema V2 with column_set + column + button layout because
|
* Uses Schema V2 with:
|
||||||
* Schema V2 does NOT support the `action` tag directly.
|
* - markdown element for text content
|
||||||
|
* - action element for buttons (Allow = primary, Deny = danger)
|
||||||
*/
|
*/
|
||||||
async function sendAsInteractiveCard(
|
async function sendAsInteractiveCard(
|
||||||
client: lark.Client,
|
client: lark.Client,
|
||||||
@@ -81,37 +172,72 @@ async function sendAsInteractiveCard(
|
|||||||
replyToMessageId?: string,
|
replyToMessageId?: string,
|
||||||
): Promise<SendResult> {
|
): Promise<SendResult> {
|
||||||
try {
|
try {
|
||||||
// Strip HTML tags for Feishu (convert <b> to **, <code> to `, <pre> to ```)
|
const mdText = htmlToFeishuMarkdown(text);
|
||||||
const mdText = text
|
|
||||||
.replace(/<b>(.*?)<\/b>/gi, '**$1**')
|
|
||||||
.replace(/<code>(.*?)<\/code>/gi, '`$1`')
|
|
||||||
.replace(/<pre>([\s\S]*?)<\/pre>/gi, '```\n$1\n```')
|
|
||||||
.replace(/<\/?[^>]+>/g, '');
|
|
||||||
|
|
||||||
// Build button elements using column_set layout (Schema V2 compatible)
|
// Build button columns — Schema V2 doesn't support 'action' tag,
|
||||||
const buttonRows = inlineButtons.map((row) => ({
|
// use column_set with individual button elements instead
|
||||||
tag: 'column_set' as const,
|
const buttonColumns = inlineButtons.flat().map((btn) => {
|
||||||
flex_mode: 'stretch' as const,
|
let btnType: 'primary' | 'danger' | 'default' = 'default';
|
||||||
columns: row.map((btn) => ({
|
const lowerText = btn.text.toLowerCase();
|
||||||
|
if (lowerText.includes('deny') || lowerText.includes('拒绝')) {
|
||||||
|
btnType = 'danger';
|
||||||
|
} else if (lowerText.includes('allow') || lowerText.includes('允许')) {
|
||||||
|
btnType = 'primary';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
tag: 'column' as const,
|
tag: 'column' as const,
|
||||||
width: 'weighted' as const,
|
width: 'auto' as const,
|
||||||
weight: 1,
|
elements: [
|
||||||
elements: [{
|
{
|
||||||
tag: 'button' as const,
|
tag: 'button' as const,
|
||||||
text: { tag: 'plain_text' as const, content: btn.text },
|
text: { tag: 'plain_text' as const, content: btn.text },
|
||||||
type: 'primary' as const,
|
type: btnType,
|
||||||
value: { callback_data: btn.callbackData, chatId },
|
size: 'medium' as const,
|
||||||
}],
|
value: { callback_data: btn.callbackData, chatId },
|
||||||
})),
|
},
|
||||||
}));
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const card = {
|
const card = {
|
||||||
schema: '2.0',
|
schema: '2.0',
|
||||||
config: { wide_screen_mode: true },
|
config: {
|
||||||
|
wide_screen_mode: true,
|
||||||
|
style: {
|
||||||
|
color: {
|
||||||
|
'light-orange-bg': {
|
||||||
|
light_mode: 'rgba(255, 214, 102, 0.12)',
|
||||||
|
dark_mode: 'rgba(255, 214, 102, 0.08)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
title: { tag: 'plain_text' as const, content: 'Permission Required' },
|
||||||
|
template: 'blue' as const,
|
||||||
|
icon: { tag: 'standard_icon' as const, token: 'lock-chat_filled' },
|
||||||
|
padding: '12px 12px 12px 12px',
|
||||||
|
},
|
||||||
body: {
|
body: {
|
||||||
elements: [
|
elements: [
|
||||||
{ tag: 'markdown', content: mdText },
|
{
|
||||||
...buttonRows,
|
tag: 'markdown' as const,
|
||||||
|
content: mdText,
|
||||||
|
text_size: 'normal' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'markdown' as const,
|
||||||
|
content: '⏱ This request will expire in 5 minutes',
|
||||||
|
text_size: 'notation' as const,
|
||||||
|
},
|
||||||
|
{ tag: 'hr' as const },
|
||||||
|
{
|
||||||
|
tag: 'column_set' as const,
|
||||||
|
flex_mode: 'none' as const,
|
||||||
|
horizontal_align: 'left' as const,
|
||||||
|
columns: buttonColumns,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -140,10 +266,50 @@ async function sendAsInteractiveCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Reactions ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a permission card with interactive buttons using bot identity (app_access_token).
|
* Add an emoji reaction to a Feishu message.
|
||||||
*
|
* Used as a "message received" acknowledgment before processing starts.
|
||||||
* OpenClaw supports both bot and user identity for card delivery.
|
*/
|
||||||
|
export async function addReaction(
|
||||||
|
client: lark.Client,
|
||||||
|
messageId: string,
|
||||||
|
emojiType: string = 'OnIt',
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const resp = await client.im.messageReaction.create({
|
||||||
|
path: { message_id: messageId },
|
||||||
|
data: { reaction_type: { emoji_type: emojiType } },
|
||||||
|
});
|
||||||
|
return (resp?.data as any)?.reaction_id || null;
|
||||||
|
} catch (err: any) {
|
||||||
|
// Non-critical — log and swallow
|
||||||
|
console.warn(LOG_TAG, 'Failed to add reaction:', err?.message || err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an emoji reaction from a Feishu message.
|
||||||
|
* Used to clear the "processing" indicator after response is sent.
|
||||||
|
*/
|
||||||
|
export async function removeReaction(
|
||||||
|
client: lark.Client,
|
||||||
|
messageId: string,
|
||||||
|
reactionId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await client.im.messageReaction.delete({
|
||||||
|
path: { message_id: messageId, reaction_id: reactionId },
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.warn(LOG_TAG, 'Failed to remove reaction:', err?.message || err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a pre-built card (for permission cards or custom cards).
|
||||||
*/
|
*/
|
||||||
export async function sendPermissionCard(
|
export async function sendPermissionCard(
|
||||||
client: lark.Client,
|
client: lark.Client,
|
||||||
@@ -158,19 +324,12 @@ export async function sendPermissionCard(
|
|||||||
if (replyToMessageId) {
|
if (replyToMessageId) {
|
||||||
resp = await client.im.message.reply({
|
resp = await client.im.message.reply({
|
||||||
path: { message_id: replyToMessageId },
|
path: { message_id: replyToMessageId },
|
||||||
data: {
|
data: { content: cardContent, msg_type: 'interactive' },
|
||||||
content: cardContent,
|
|
||||||
msg_type: 'interactive',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
resp = await client.im.message.create({
|
resp = await client.im.message.create({
|
||||||
params: { receive_id_type: 'chat_id' },
|
params: { receive_id_type: 'chat_id' },
|
||||||
data: {
|
data: { receive_id: realChatId, content: cardContent, msg_type: 'interactive' },
|
||||||
receive_id: realChatId,
|
|
||||||
content: cardContent,
|
|
||||||
msg_type: 'interactive',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user