diff --git a/README.md b/README.md index 83d2993..6a84f44 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ - ✅ PushPlus 微信推送(国内推荐,无需翻墙) - ✅ Server酱 微信推送(Turbo 版,扫码获取 SendKey 即用) +- ✅ 企业微信群机器人推送(群机器人 Webhook,无需第三方服务) - ✅ 微信 ClawBot 推送(腾讯官方 iLink 直连,免费无第三方服务) - ✅ Discord / Telegram / ntfy 推送 - ✅ 全面日志记录(日志过滤 + 落盘 `logs/` 按天分文件,自动清理 90 天前旧日志) @@ -213,7 +214,7 @@ ACCOUNT_1_PASSWORD=your_password - `RUN_ON_START`:容器启动时是否立即跑一次(默认 `true`) - `CONFIG_SEARCH_QUERY_ENGINES`:查询源,国内推荐 `china,local` - `CONFIG_CHINA_API_APPKEY`:gmya.net appkey,解除免费档限流(留空走免费档) -- `CONFIG_PUSHPLUS_*` / `CONFIG_SERVERCHAN_*` / `CONFIG_CLAWBOT_*`:微信推送 +- `CONFIG_PUSHPLUS_*` / `CONFIG_SERVERCHAN_*` / `CONFIG_QYWXBOT_*` / `CONFIG_CLAWBOT_*`:微信推送 > 完整的 `CONFIG_*` 环境变量列表见[配置参考](#️-配置参考)各表格。`CONFIG_*` 每次启动都会覆盖 `./config/config.json`。 @@ -471,10 +472,12 @@ docker compose restart # 重启(不重建) | `webhook.ntfy.enabled` / `.url` / `.topic` / `.token` 等 | ntfy 推送 | `false` | `CONFIG_NTFY_*` | | `webhook.pushplus.enabled` / `.token` / `.title` / `.template` / `.channel` | PushPlus 微信推送 | `false` | `CONFIG_PUSHPLUS_*` | | `webhook.serverchan.enabled` / `.sendKey` / `.title` | Server酱 微信推送(Turbo) | `false` | `CONFIG_SERVERCHAN_ENABLED` / `_SENDKEY` / `_TITLE` | +| `webhook.qywxbot.enabled` / `.sendKey` | 企业微信群机器人推送 | `false` | `CONFIG_QYWXBOT_ENABLED` / `_SENDKEY` | | `webhook.clawbot.enabled` / `.authFile` | 微信 ClawBot 推送 | `false` | `CONFIG_CLAWBOT_ENABLED` / `_AUTHFILE` | | `webhook.webhookLogFilter.*` | Webhook 逐条日志过滤(结构同 consoleLogFilter) | `false` | `CONFIG_WEBHOOK_LOG_FILTER_*` | -> 💡 **国内推荐**:**PushPlus** 或 **微信 ClawBot**(均直达微信,无需翻墙)。Discord / Telegram / ntfy 需要能访问对应服务。 +> 💡 **国内推荐**:**PushPlus**、**Server酱**、**企业微信群机器人** 或 **微信 ClawBot**(均直达微信,无需翻墙)。Discord / Telegram / ntfy 需要能访问对应服务。 +> 企业微信群机器人的 `sendKey` 取群机器人 Webhook 地址中 `?key=` 后面那段(群设置 → 群机器人 → 添加机器人)。 > 运行结束的中文积分摘要会一次性推送(不逐条推日志)。
diff --git a/compose.yaml b/compose.yaml index 3e7003d..c038489 100644 --- a/compose.yaml +++ b/compose.yaml @@ -67,6 +67,9 @@ services: #CONFIG_SERVERCHAN_ENABLED: 'true' #CONFIG_SERVERCHAN_SENDKEY: 'SCTxxxxxxxxxxxxxxxx' #CONFIG_SERVERCHAN_TITLE: 'Microsoft-Rewards-Script' + # 企业微信群机器人推送(群设置 → 群机器人 → 添加,取 Webhook 地址中 key= 后的串): + #CONFIG_QYWXBOT_ENABLED: 'true' + #CONFIG_QYWXBOT_SENDKEY: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' # 微信 ClawBot 推送(官方 iLink 直连,免费;需容器内完成一次扫码, # 建议配合 API_MODE/tty 使用,或本地登录后把 clawbot-auth.json 挂载进容器): #CONFIG_CLAWBOT_ENABLED: 'true' diff --git a/config.example.json b/config.example.json index d01b352..1a41739 100644 --- a/config.example.json +++ b/config.example.json @@ -96,6 +96,10 @@ "sendKey": "", "title": "Microsoft-Rewards-Script" }, + "qywxbot": { + "enabled": false, + "sendKey": "" + }, "clawbot": { "enabled": false, "authFile": "" diff --git a/src/index.ts b/src/index.ts index 7e2aa70..fa6ba9c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,7 @@ import { sendNtfy, flushNtfyQueue } from './logging/Ntfy' import { sendTelegram, flushTelegramQueue } from './logging/Telegram' import { sendPushPlus, flushPushPlusQueue } from './logging/PushPlus' import { sendServerChan, flushServerChanQueue } from './logging/ServerChan' +import { sendQywxBot, flushQywxBotQueue } from './logging/QywxBot' import { sendClawBot, flushClawBotQueue, ensureClawBotReady } from './logging/ClawBot' import type { DashboardData } from './interface/DashboardData' import type { AppDashboardData } from './interface/AppDashBoardData' @@ -79,6 +80,7 @@ async function flushAllWebhooks(timeoutMs = 5000): Promise { flushTelegramQueue(timeoutMs), flushPushPlusQueue(timeoutMs), flushServerChanQueue(timeoutMs), + flushQywxBotQueue(timeoutMs), flushClawBotQueue(timeoutMs) ]) closeSessionStore() @@ -391,6 +393,7 @@ export class MicrosoftRewardsBot { await this.sendPushPlusSummary(allAccountStats, runStartTime, hadWorkerFailure) await this.sendServerChanSummary(allAccountStats, runStartTime, hadWorkerFailure) + await this.sendQywxBotPushSummary(allAccountStats, runStartTime, hadWorkerFailure) await this.sendClawBotSummary(allAccountStats, runStartTime, hadWorkerFailure) await flushAllWebhooks() @@ -501,6 +504,30 @@ export class MicrosoftRewardsBot { await sendServerChan(serverchan, content) } + private async sendQywxBotPushSummary( + accountStats: AccountStats[], + runStartTime: number, + hadWorkerFailure: boolean + ): Promise { + const qywxbot = this.config?.webhook?.qywxbot + if (!qywxbot?.enabled || !qywxbot.sendKey) { + return + } + + const content = this.buildSummaryMessage(accountStats, runStartTime, hadWorkerFailure) + const result = await sendQywxBot(qywxbot, content) + + if (result.skipped) { + return + } + + if (result.ok) { + this.logger.info('main', 'QYWXBOT', '企业微信机器人推送成功', 'green') + } else { + this.logger.warn('main', 'QYWXBOT', `企业微信机器人推送失败 | ${result.message ?? '未知错误'}`) + } + } + private async sendClawBotSummary( accountStats: AccountStats[], runStartTime: number, @@ -645,6 +672,7 @@ export class MicrosoftRewardsBot { const hadFailure = accountStats.some(s => !s.success) await this.sendPushPlusSummary(accountStats, runStartTime, hadFailure) await this.sendServerChanSummary(accountStats, runStartTime, hadFailure) + await this.sendQywxBotPushSummary(accountStats, runStartTime, hadFailure) await this.sendClawBotSummary(accountStats, runStartTime, hadFailure) await flushAllWebhooks() diff --git a/src/interface/Config.ts b/src/interface/Config.ts index d41be1b..03f632f 100644 --- a/src/interface/Config.ts +++ b/src/interface/Config.ts @@ -111,6 +111,7 @@ export interface ConfigWebhook { telegram?: WebhookTelegramConfig pushplus?: WebhookPushPlusConfig serverchan?: WebhookServerChanConfig + qywxbot?: WebhookQywxBotConfig clawbot?: WebhookClawBotConfig webhookLogFilter: LogFilter } @@ -158,6 +159,11 @@ export interface WebhookServerChanConfig { title?: string } +export interface WebhookQywxBotConfig { + enabled?: boolean + sendKey: string +} + export interface WebhookClawBotConfig { enabled?: boolean authFile?: string diff --git a/src/logging/QywxBot.ts b/src/logging/QywxBot.ts new file mode 100644 index 0000000..9e237e7 --- /dev/null +++ b/src/logging/QywxBot.ts @@ -0,0 +1,64 @@ +import { httpRequest } from '../util/Http' +import type { HttpRequestConfig } from '../util/Http' +import PQueue from 'p-queue' +import type { WebhookQywxBotConfig } from '../interface/Config' +import { flushQueue } from './Queue' + +const qywxBotQueue = new PQueue({ + interval: 1000, + intervalCap: 2, + carryoverConcurrencyCount: true +}) + +const QYWX_BOT_API = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send' +const TITLE = 'Microsoft-Rewards-Script' + +export interface QywxBotSendResult { + ok: boolean + /** 未启用或缺少 sendKey,调用方无需记录日志 */ + skipped?: boolean + message?: string +} + +export async function sendQywxBot(config: WebhookQywxBotConfig, content: string): Promise { + if (!config?.enabled || !config?.sendKey) { + return { ok: false, skipped: true, message: '未启用或 sendKey 为空' } + } + + const request: HttpRequestConfig = { + method: 'POST', + url: `${QYWX_BOT_API}?key=${config.sendKey}`, + headers: { 'Content-Type': 'application/json;charset=utf-8' }, + data: { + msgtype: 'text', + text: { + content: `${TITLE}\n\n${content}` + } + }, + timeout: 15000 + } + + return qywxBotQueue.add(async () => { + try { + const response = await httpRequest<{ errcode?: number; errmsg?: string }>(request) + const { errcode, errmsg } = response?.data ?? {} + + if (errcode === undefined || errcode === 0) { + return { ok: true } + } + + return { ok: false, message: `errcode=${errcode}${errmsg ? ` errmsg=${errmsg}` : ''}` } + } catch (err) { + const status = (err as { response?: { status?: number } })?.response?.status + if (status === 429) { + return { ok: false, message: '触发接口频率限制(429),本次跳过' } + } + + return { ok: false, message: err instanceof Error ? err.message : String(err) } + } + }) +} + +export function flushQywxBotQueue(timeoutMs = 5000): Promise { + return flushQueue(qywxBotQueue, timeoutMs) +} diff --git a/src/util/ConfigEnvOverrides.ts b/src/util/ConfigEnvOverrides.ts index 581eb48..62eb4fa 100644 --- a/src/util/ConfigEnvOverrides.ts +++ b/src/util/ConfigEnvOverrides.ts @@ -93,6 +93,10 @@ export const ENV_OVERRIDES: EnvOverrideEntry[] = [ { env: 'CONFIG_SERVERCHAN_SENDKEY', path: 'webhook.serverchan.sendKey', type: 'string' }, { env: 'CONFIG_SERVERCHAN_TITLE', path: 'webhook.serverchan.title', type: 'string' }, + // 企业微信群机器人 webhook(国内微信推送,sendKey 为群机器人 Webhook 地址中的 key 参数) + { env: 'CONFIG_QYWXBOT_ENABLED', path: 'webhook.qywxbot.enabled', type: 'bool' }, + { env: 'CONFIG_QYWXBOT_SENDKEY', path: 'webhook.qywxbot.sendKey', type: 'string' }, + // Humanize 拟人化运行策略(quietHours 为数组结构,请直接编辑 config.json) { env: 'CONFIG_HUMANIZE_ENABLED', path: 'humanize.enabled', type: 'bool' }, { env: 'CONFIG_HUMANIZE_SKIP_WHEN_COMPLETED', path: 'humanize.skipWhenCompletedToday', type: 'bool' }, diff --git a/src/util/Validator.ts b/src/util/Validator.ts index 9c85b95..517b5d9 100644 --- a/src/util/Validator.ts +++ b/src/util/Validator.ts @@ -126,6 +126,19 @@ const WebhookSchema = z.object({ channel: z.string().optional() }) .optional(), + serverchan: z + .object({ + enabled: z.boolean().optional(), + sendKey: z.string(), + title: z.string().optional() + }) + .optional(), + qywxbot: z + .object({ + enabled: z.boolean().optional(), + sendKey: z.string() + }) + .optional(), clawbot: z .object({ enabled: z.boolean().optional(),