#!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; import vm from "node:vm"; import { Blob } from "node:buffer"; const SDK_BUNDLES = [ "https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/lib-polyfill.f81f86eb.js", "https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/lib-router.5ab9ff10.js", "https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/2105.f8d74876.js", "https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/douyin_creator_data_old.2f971672.js", "https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/argus-builder-strategy.5a053c46.js", "https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/7676.a4cd4900.js", "https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/4916.56c33d22.js", "https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/8198.b5c0b108.js", "https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/4168.b2e72401.js", "https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/7771.d27d1891.js", "https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/6682.2a991dfb.js", "https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/361.4fc40815.js", "https://lf-fe-creator.douyinstatic.com/obj/douyn-creator-scm-cdn/douyin-creator-mono-pc-data/static/js/async/pages-chat.c817de31.js", ]; const CREATOR_CHAT_URL = "https://creator.douyin.com/creator-micro/data/following/chat"; const USER_AGENT = (process.env.SPARKFLOW_PROTOCOL_USER_AGENT || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36").trim(); function noop() {} function toCookieString(cookies) { return (cookies || []) .filter((item) => item?.name && item?.value !== undefined) .map((item) => `${item.name}=${item.value}`) .join("; "); } function normalizeNickname(value) { return String(value || "").trim(); } function stableNow() { return new Date().toISOString(); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function toNonNegativeInteger(value, fallback = 0) { const parsed = Number.parseInt(value, 10); if (Number.isNaN(parsed) || parsed < 0) { return fallback; } return parsed; } function normalizeSendStrategy(raw = {}) { const intervalMin = toNonNegativeInteger(raw.messageIntervalSecondsMin, 0); const intervalMax = Math.max(intervalMin, toNonNegativeInteger(raw.messageIntervalSecondsMax, intervalMin)); return { messageIntervalSecondsMin: intervalMin, messageIntervalSecondsMax: intervalMax, }; } function randomBetweenInclusive(min, max) { if (max <= min) { return min; } return Math.floor(Math.random() * (max - min + 1)) + min; } const SEND_MESSAGE_STATUS_NAMES = { 0: "Succeeded", 1: "UserNotInConversation", 2: "CheckConversationNotPass", 3: "CheckMessageNotPass", 4: "CheckMessageNotPassButSelfVisible", 5: "UserHasBeenBlock", }; function sendMessageStatusName(statusCode) { if (statusCode === null || statusCode === undefined) { return ""; } return SEND_MESSAGE_STATUS_NAMES[Number(statusCode)] || "Unknown"; } function publicSendResultSummary(sendResult) { if (!sendResult || typeof sendResult !== "object") { return {}; } const summary = {}; for (const key of ["success", "statusCode", "statusMsg", "checkCode", "checkMsg", "errorCode", "errorMsg"]) { if (sendResult[key] !== undefined) { summary[key] = sendResult[key]; } } summary.rawKeys = Object.keys(sendResult).sort(); return summary; } async function readStdinJson() { const chunks = []; for await (const chunk of process.stdin) { chunks.push(chunk); } const raw = Buffer.concat(chunks).toString("utf8").trim(); if (!raw) { throw new Error("Missing JSON payload on stdin"); } return JSON.parse(raw); } async function ensureBundles(cacheDir) { await fs.promises.mkdir(cacheDir, { recursive: true }); for (const url of SDK_BUNDLES) { const filename = url.split("/").at(-1); const filePath = path.join(cacheDir, filename); if (fs.existsSync(filePath)) { const response = await fetch(url, { method: "HEAD", headers: { "User-Agent": USER_AGENT } }); if (!response.ok) { throw new Error(`Cached SDK bundle is stale or unreachable ${url}: ${response.status}`); } continue; } const response = await fetch(url, { headers: { "User-Agent": USER_AGENT } }); if (!response.ok) { throw new Error(`Failed to download SDK bundle ${url}: ${response.status}`); } const text = await response.text(); await fs.promises.writeFile(filePath, text, "utf8"); } } function createWebpackRequire(bundleDir, cookieString) { const modules = {}; const cache = {}; function requireModule(id) { if (cache[id]) { return cache[id].exports; } if (!modules[id]) { throw new Error(`Missing webpack module ${id}`); } const module = { exports: {} }; cache[id] = module; modules[id].call(module.exports, module, module.exports, requireModule); return module.exports; } requireModule.d = (exports, definition) => { for (const key of Object.keys(definition)) { if (!Object.prototype.hasOwnProperty.call(exports, key)) { Object.defineProperty(exports, key, { enumerable: true, get: definition[key], }); } } }; requireModule.o = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop); requireModule.r = (exports) => { if (typeof Symbol !== "undefined" && Symbol.toStringTag) { Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); } Object.defineProperty(exports, "__esModule", { value: true }); }; requireModule.n = (mod) => { const getter = mod && mod.__esModule ? () => mod.default : () => mod; requireModule.d(getter, { a: getter }); return getter; }; requireModule.g = globalThis; requireModule.hmd = (module) => module; requireModule.nmd = (module) => module; const chunkArray = []; chunkArray.push = (chunk) => Object.assign(modules, chunk[1]); const fakeElement = () => ({ style: {}, setAttribute: noop, appendChild: noop, removeChild: noop, addEventListener: noop, removeEventListener: noop, getContext: () => ({}), }); const documentRef = { cookie: cookieString, referrer: CREATOR_CHAT_URL, createElement: fakeElement, getElementsByTagName: () => [], querySelector: () => null, querySelectorAll: () => [], addEventListener: noop, removeEventListener: noop, body: { appendChild: noop, removeChild: noop }, head: { appendChild: noop, removeChild: noop }, documentElement: { style: {} }, }; function XMLHttpRequestStub() { this.open = noop; this.setRequestHeader = noop; this.send = noop; } function WebSocketStub() { this.readyState = 1; this.send = noop; this.close = noop; } const context = { self: { webpackChunkdouyin_creator_data: chunkArray }, window: {}, globalThis: null, console, setTimeout, clearTimeout, setInterval, clearInterval, Buffer, TextDecoder, TextEncoder, Blob, document: documentRef, navigator: { userAgent: USER_AGENT, language: "en-US", cookieEnabled: true, onLine: true, platform: "Linux x86_64", sendBeacon: undefined, appName: "Netscape", }, location: { href: CREATOR_CHAT_URL, protocol: "https:", search: "", pathname: "/creator-micro/data/following/chat", hostname: "creator.douyin.com", }, localStorage: { getItem: () => null, setItem: noop, removeItem: noop }, sessionStorage: { getItem: () => null, setItem: noop, removeItem: noop }, performance: { now: () => Date.now() }, fetch, XMLHttpRequest: XMLHttpRequestStub, WebSocket: WebSocketStub, URL, URLSearchParams, atob: (value) => Buffer.from(value, "base64").toString("binary"), btoa: (value) => Buffer.from(value, "binary").toString("base64"), crypto, }; context.window = context; context.globalThis = context; for (const entry of fs.readdirSync(bundleDir).filter((name) => name.endsWith(".js")).sort()) { const code = fs.readFileSync(path.join(bundleDir, entry), "utf8"); try { vm.runInNewContext(code, context, { filename: entry }); } catch { // Some bundles execute browser-only entrypoints after registering modules. } } return requireModule; } class ProtocolError extends Error { constructor(message, details = {}) { super(message); this.name = "ProtocolError"; this.details = details; } } function extractCookieMap(cookies) { const items = {}; for (const item of cookies || []) { if (item?.name) { items[item.name] = item.value ?? ""; } } return items; } function buildCreatorHeaders(cookieString, cookieMap, referer = CREATOR_CHAT_URL) { return { "User-Agent": USER_AGENT, Referer: referer, Origin: "https://creator.douyin.com", Accept: "application/json, text/javascript", "Content-Type": "application/x-www-form-urlencoded", Cookie: cookieString, "x-tt-passport-csrf-token": cookieMap.passport_csrf_token || cookieMap.passport_csrf_token_default || "", }; } function buildImHeaders(cookieString) { return { "User-Agent": USER_AGENT, Referer: CREATOR_CHAT_URL, Origin: "https://creator.douyin.com", Cookie: cookieString, }; } async function fetchJson(url, options = {}) { const timeoutMs = options.timeoutMs || 15000; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); const response = await fetch(url, { ...options, signal: controller.signal, }); const text = await response.text(); clearTimeout(timer); let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = null; } return { response, text, data }; } async function fetchSessionIdentity(cookieString, cookieMap) { const headers = buildCreatorHeaders(cookieString, cookieMap); const params = new URLSearchParams({ aid: "2906", app_name: "aweme_creator_platform", device_platform: "web", referer: "", user_agent: USER_AGENT, cookie_enabled: "true", screen_width: "1280", screen_height: "720", browser_language: "en-US@posix", browser_platform: "Linux x86_64", browser_name: "Mozilla", browser_version: USER_AGENT, browser_online: "true", timezone_name: "Asia/Shanghai", }); const { response, data, text } = await fetchJson( `https://creator.douyin.com/aweme/v1/creator/im/user_token/?${params.toString()}`, { headers }, ); if (!response.ok || data?.status_code !== 0 || !data?.user_id) { throw new ProtocolError("Failed to resolve creator IM session identity", { status: response.status, body: text, }); } return { userId: String(data.user_id), sessionToken: String(data.token || ""), }; } async function fetchIdentitySecurityToken(cookieString, cookieMap) { const headers = buildCreatorHeaders(cookieString, cookieMap); const params = new URLSearchParams({ scene: "im_send_msg", auto_retry_req: "0", skip_verify: "0", identity_token_force_get_tag: "0", passport_jssdk_version: "5.1.4", passport_jssdk_type: "lite", is_from_ttaccountsdk: "1", aid: "2906", language: "zh", account_app_language: "en-US", id_token_version: "2.1.5", }); const { response, data, text } = await fetchJson( `https://creator.douyin.com/passport/safe/get_identity_security_token/?${params.toString()}`, { headers }, ); if (!response.ok || data?.message !== "success" || !data?.data?.identity_security_token) { throw new ProtocolError("Failed to resolve identity security token", { status: response.status, body: text, }); } return { identitySecurityHeader: JSON.stringify({ token: data.data.identity_security_token }), realDeviceId: String(data.data.device_id || ""), }; } async function fetchProfileNickname(cookieString, secUid) { const url = "https://www.douyin.com/aweme/v1/web/user/profile/other/?" + new URLSearchParams({ sec_user_id: secUid }).toString(); const { response, data, text } = await fetchJson(url, { headers: { "User-Agent": USER_AGENT, Referer: `https://www.douyin.com/user/${secUid}`, Cookie: cookieString, Accept: "application/json, text/javascript", }, }); if (!response.ok || data?.status_code !== 0) { return ""; } return normalizeNickname(data?.user?.nickname); } function stringifyMaybeLong(value) { if (value === null || value === undefined) { return ""; } if (typeof value === "string" || typeof value === "number" || typeof value === "bigint") { return String(value); } if (typeof value.toString === "function" && value.toString !== Object.prototype.toString) { return value.toString(); } return String(value); } function selectPeerParticipant(conversation, selfUserId) { const participants = conversation?.firstPageParticipant?.participants || []; for (const participant of participants) { const currentUserId = stringifyMaybeLong(participant?.user_id); if (currentUserId && currentUserId !== selfUserId) { return participant; } } return null; } async function createProtocolClient({ bundleDir, cookieString, cookieMap, userId }) { const requireModule = createWebpackRequire(bundleDir, cookieString); const sdk = requireModule(61724); const { BytedIM } = requireModule(26440); class AdditionalParamsPlugin extends sdk.BasePlugin { install() {} async sendPacket(packet) { packet.device_id = 0; packet.device_platform = "douyin_creator"; packet.headers = { ...(packet.headers || {}), aid_new: 2906, app_name: "douyin_creator", }; return packet; } } class NodeHttpClient extends sdk.IMHttpClient { async send(url, method, body) { const fullUrl = /^https?:/i.test(url) ? url : `${String(this.option.apiUrl).replace(/\/$/, "")}/${String(url).replace(/^\//, "")}`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 20000); const response = await fetch(fullUrl, { method, headers: this.headers, body: body ? Buffer.from(body) : undefined, signal: controller.signal, }); clearTimeout(timer); return response.arrayBuffer(); } sendByBeacon() { return false; } } const client = new BytedIM( { appId: 2906, fpId: 9, appKey: "e1bd35ec9db7b8d846de66ed140b1ad9", service: 5, apiUrl: "https://imapi.douyin.com", frontierUrl: "wss://frontier-im.douyin.com/ws/v2", inboxType: 1, token: "", userId, deviceId: userId, authType: sdk.im_proto.AuthType.SESSION_AUTH, devicePlatform: "douyin_pc", timeout: 20000, acceptIncorrectInboxType: true, biz: "douyin_creator", withCredentials: false, httpHeaders: buildImHeaders(cookieString), headers: {}, webSocketLevel: sdk.WebSocketLevel.PushOnly, debug: false, http: (ctx) => new NodeHttpClient(ctx), }, [AdditionalParamsPlugin], ); const initResult = await client.init(); if (initResult !== sdk.InitResult.Succeeded) { throw new ProtocolError("Protocol IM init did not succeed", { initResult }); } return { client }; } async function buildConversationCache({ client, selfUserId, cookieString, existingCache = [], targetNames = [], }) { const cachedBySecUid = new Map( (existingCache || []).filter((entry) => entry?.secUid).map((entry) => [entry.secUid, entry]), ); const wantedTargets = new Set((targetNames || []).map(normalizeNickname).filter(Boolean)); const matchedTargets = new Set(); const conversations = await client.getConversationListOnline(); const cacheEntries = []; for (const conversation of conversations) { if (conversation?.type !== 1) { continue; } const peer = selectPeerParticipant(conversation, selfUserId); if (!peer) { continue; } const peerUserId = stringifyMaybeLong(peer.user_id); const secUid = peer.sec_uid || ""; if (!peerUserId || !secUid) { continue; } let nickname = normalizeNickname(cachedBySecUid.get(secUid)?.nickname); if (!nickname) { try { nickname = await fetchProfileNickname(cookieString, secUid); } catch { nickname = ""; } } cacheEntries.push({ nickname, peerUserId, secUid, conversationId: conversation.id, conversationShortId: conversation.shortId, updatedAt: stableNow(), }); if (nickname && wantedTargets.has(nickname)) { matchedTargets.add(nickname); if (matchedTargets.size === wantedTargets.size) { break; } } } const deduped = new Map(); for (const entry of existingCache || []) { if (!entry?.nickname || !entry?.secUid) { continue; } deduped.set(entry.secUid, entry); } for (const entry of cacheEntries) { if (!entry.nickname) { continue; } deduped.set(entry.secUid, entry); } return Array.from(deduped.values()).sort((left, right) => left.nickname.localeCompare(right.nickname, "zh-CN"), ); } function buildTargetLookup(cacheEntries) { const byNickname = new Map(); for (const entry of cacheEntries) { const key = normalizeNickname(entry.nickname); if (key && !byNickname.has(key)) { byNickname.set(key, entry); } } return byNickname; } async function sendMessages({ client, cacheEntries, messagesByTarget, dryRun, cookieString, cookieMap, sendStrategy, }) { if (!dryRun) { const identity = await fetchIdentitySecurityToken(cookieString, cookieMap); client.updateSendMessageHeaders({ identity_security_token: identity.identitySecurityHeader, identity_security_device_id: identity.realDeviceId, identity_security_aid: "2906", }); } const byNickname = buildTargetLookup(cacheEntries); const resolved = []; const unresolved = []; const sent = []; const normalizedStrategy = normalizeSendStrategy(sendStrategy); for (const [target, message] of Object.entries(messagesByTarget)) { const mapping = byNickname.get(normalizeNickname(target)); if (!mapping) { unresolved.push({ target, reason: "conversation_not_found" }); continue; } const conversation = client.getConversation({ conversationId: mapping.conversationId }); if (!conversation) { unresolved.push({ target, reason: "conversation_not_loaded", mapping }); continue; } resolved.push({ target, nickname: mapping.nickname, peerUserId: mapping.peerUserId, conversationId: mapping.conversationId, conversationShortId: mapping.conversationShortId, }); let delayBeforeSendSeconds = 0; if (!dryRun && sent.length > 0 && normalizedStrategy.messageIntervalSecondsMax > 0) { delayBeforeSendSeconds = randomBetweenInclusive( normalizedStrategy.messageIntervalSecondsMin, normalizedStrategy.messageIntervalSecondsMax, ); if (delayBeforeSendSeconds > 0) { await sleep(delayBeforeSendSeconds * 1000); } } const payload = JSON.stringify({ text: message, aweType: 774 }); const messageObject = await client.createMessage({ type: 7, content: payload, conversation, insert: false, }); if (dryRun) { sent.push({ target, dryRun: true, message, payload, conversationId: mapping.conversationId, delayBeforeSendSeconds, }); continue; } const sendResult = await client.sendMessage({ message: messageObject }); const statusCode = sendResult?.statusCode ?? null; sent.push({ target, dryRun: false, message, success: Boolean(sendResult?.success), statusCode, statusName: sendMessageStatusName(statusCode), statusMsg: sendResult?.statusMsg ?? "", sendResultSummary: publicSendResultSummary(sendResult), conversationId: mapping.conversationId, delayBeforeSendSeconds, sentAt: stableNow(), }); } return { resolved, unresolved, sent }; } async function main() { const payload = await readStdinJson(); const repoRoot = payload.repoRoot || process.cwd(); const bundleDir = path.join(repoRoot, ".im_sdk_cache"); await ensureBundles(bundleDir); const account = payload.account || {}; const cookieString = toCookieString(account.cookies); const cookieMap = extractCookieMap(account.cookies); const { userId } = await fetchSessionIdentity(cookieString, cookieMap); const { client } = await createProtocolClient({ bundleDir, cookieString, cookieMap, userId, }); const cacheEntries = await buildConversationCache({ client, selfUserId: userId, cookieString, existingCache: account.protocol_targets_cache || [], targetNames: Object.keys(payload.messagesByTarget || {}), }); const execution = await sendMessages({ client, cacheEntries, messagesByTarget: payload.messagesByTarget || {}, dryRun: Boolean(payload.dryRun), cookieString, cookieMap, sendStrategy: payload.sendStrategy || {}, }); try { console.log( JSON.stringify( { ok: true, username: account.username || "", userId, dryRun: Boolean(payload.dryRun), protocol_targets_cache: cacheEntries, ...execution, }, null, 2, ), ); } finally { await client.dispose(); } } main().catch((error) => { console.log( JSON.stringify( { ok: false, error: error?.message || String(error), details: error?.details || {}, stack: error?.stack || "", }, null, 2, ), ); process.exit(1); });