Merge branch 'main' into feat/channel_enhancement

Keep the channel enhancements aligned with the current codebase while preserving a simpler product surface. This keeps QQ, Feishu, Telegram, and WhatsApp improvements together, removes the extra Telegram-only tool hint toggle, and makes WhatsApp mention-only groups actually work.
This commit is contained in:
Xubin Ren
2026-03-24 03:33:44 +00:00
34 changed files with 2367 additions and 219 deletions

View File

@@ -12,6 +12,17 @@ interface SendCommand {
text: string;
}
interface SendMediaCommand {
type: 'send_media';
to: string;
filePath: string;
mimetype: string;
caption?: string;
fileName?: string;
}
type BridgeCommand = SendCommand | SendMediaCommand;
interface BridgeMessage {
type: 'message' | 'status' | 'qr' | 'error';
[key: string]: unknown;
@@ -72,7 +83,7 @@ export class BridgeServer {
ws.on('message', async (data) => {
try {
const cmd = JSON.parse(data.toString()) as SendCommand;
const cmd = JSON.parse(data.toString()) as BridgeCommand;
await this.handleCommand(cmd);
ws.send(JSON.stringify({ type: 'sent', to: cmd.to }));
} catch (error) {
@@ -92,9 +103,13 @@ export class BridgeServer {
});
}
private async handleCommand(cmd: SendCommand): Promise<void> {
if (cmd.type === 'send' && this.wa) {
private async handleCommand(cmd: BridgeCommand): Promise<void> {
if (!this.wa) return;
if (cmd.type === 'send') {
await this.wa.sendMessage(cmd.to, cmd.text);
} else if (cmd.type === 'send_media') {
await this.wa.sendMedia(cmd.to, cmd.filePath, cmd.mimetype, cmd.caption, cmd.fileName);
}
}

View File

@@ -16,8 +16,8 @@ import makeWASocket, {
import { Boom } from '@hapi/boom';
import qrcode from 'qrcode-terminal';
import pino from 'pino';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { readFile, writeFile, mkdir } from 'fs/promises';
import { join, basename } from 'path';
import { randomBytes } from 'crypto';
const VERSION = '0.1.0';
@@ -29,6 +29,7 @@ export interface InboundMessage {
content: string;
timestamp: number;
isGroup: boolean;
wasMentioned?: boolean;
media?: string[];
}
@@ -48,6 +49,31 @@ export class WhatsAppClient {
this.options = options;
}
private normalizeJid(jid: string | undefined | null): string {
return (jid || '').split(':')[0];
}
private wasMentioned(msg: any): boolean {
if (!msg?.key?.remoteJid?.endsWith('@g.us')) return false;
const candidates = [
msg?.message?.extendedTextMessage?.contextInfo?.mentionedJid,
msg?.message?.imageMessage?.contextInfo?.mentionedJid,
msg?.message?.videoMessage?.contextInfo?.mentionedJid,
msg?.message?.documentMessage?.contextInfo?.mentionedJid,
msg?.message?.audioMessage?.contextInfo?.mentionedJid,
];
const mentioned = candidates.flatMap((items) => (Array.isArray(items) ? items : []));
if (mentioned.length === 0) return false;
const selfIds = new Set(
[this.sock?.user?.id, this.sock?.user?.lid, this.sock?.user?.jid]
.map((jid) => this.normalizeJid(jid))
.filter(Boolean),
);
return mentioned.some((jid: string) => selfIds.has(this.normalizeJid(jid)));
}
async connect(): Promise<void> {
const logger = pino({ level: 'silent' });
const { state, saveCreds } = await useMultiFileAuthState(this.options.authDir);
@@ -145,6 +171,7 @@ export class WhatsAppClient {
if (!finalContent && mediaPaths.length === 0) continue;
const isGroup = msg.key.remoteJid?.endsWith('@g.us') || false;
const wasMentioned = this.wasMentioned(msg);
this.options.onMessage({
id: msg.key.id || '',
@@ -153,6 +180,7 @@ export class WhatsAppClient {
content: finalContent,
timestamp: msg.messageTimestamp as number,
isGroup,
...(isGroup ? { wasMentioned } : {}),
...(mediaPaths.length > 0 ? { media: mediaPaths } : {}),
});
}
@@ -230,6 +258,32 @@ export class WhatsAppClient {
await this.sock.sendMessage(to, { text });
}
async sendMedia(
to: string,
filePath: string,
mimetype: string,
caption?: string,
fileName?: string,
): Promise<void> {
if (!this.sock) {
throw new Error('Not connected');
}
const buffer = await readFile(filePath);
const category = mimetype.split('/')[0];
if (category === 'image') {
await this.sock.sendMessage(to, { image: buffer, caption: caption || undefined, mimetype });
} else if (category === 'video') {
await this.sock.sendMessage(to, { video: buffer, caption: caption || undefined, mimetype });
} else if (category === 'audio') {
await this.sock.sendMessage(to, { audio: buffer, mimetype });
} else {
const name = fileName || basename(filePath);
await this.sock.sendMessage(to, { document: buffer, mimetype, fileName: name });
}
}
async disconnect(): Promise<void> {
if (this.sock) {
this.sock.end(undefined);