#!/usr/bin/env node

// src/cli.ts
import { createRequire as createRequire2 } from "module";
import { spawn as spawn2 } from "child_process";
import fsSync from "fs";
import fs7 from "fs/promises";
import net from "net";
import os5 from "os";
import path7 from "path";
import readline from "readline/promises";
import util from "util";
import { Command } from "commander";
import {
  DestType,
  Gender,
  Reactions,
  ReviewPendingMemberRequestStatus,
  ThreadType as ThreadType3
} from "zca-js";

// src/lib/store.ts
import fs from "fs/promises";
import os from "os";
import path from "path";
var PROFILE_NAME_RE = /^[A-Za-z0-9_-]+$/;
var APP_HOME = process.env.OPENZCA_HOME && process.env.OPENZCA_HOME.trim().length > 0 ? process.env.OPENZCA_HOME : path.join(os.homedir(), ".openzca");
var PROFILES_FILE = path.join(APP_HOME, "profiles.json");
var DEFAULT_PROFILE = "default";
async function fileExists(filePath) {
  try {
    await fs.access(filePath);
    return true;
  } catch {
    return false;
  }
}
async function ensureDir(dirPath) {
  await fs.mkdir(dirPath, { recursive: true });
}
function nowIso() {
  return (/* @__PURE__ */ new Date()).toISOString();
}
function assertProfileName(name) {
  if (!PROFILE_NAME_RE.test(name)) {
    throw new Error(
      "Invalid profile name. Use only letters, numbers, dashes, and underscores."
    );
  }
}
function getProfileDir(name) {
  return path.join(APP_HOME, "profiles", name);
}
function getCredentialsPath(name) {
  return path.join(getProfileDir(name), "credentials.json");
}
function getCacheDir(name) {
  return path.join(getProfileDir(name), "cache");
}
function getFriendsCachePath(name) {
  return path.join(getCacheDir(name), "friends.json");
}
function getGroupsCachePath(name) {
  return path.join(getCacheDir(name), "groups.json");
}
function getCacheMetaPath(name) {
  return path.join(getCacheDir(name), "meta.json");
}
async function readJsonFile(filePath) {
  if (!await fileExists(filePath)) return null;
  const raw = await fs.readFile(filePath, "utf8");
  return JSON.parse(raw);
}
async function writeJsonFile(filePath, value) {
  await ensureDir(path.dirname(filePath));
  await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}
`, "utf8");
}
async function ensureProfilesDb() {
  await ensureDir(APP_HOME);
  const current = await readJsonFile(PROFILES_FILE);
  if (current && current.profiles && current.defaultProfile) {
    return current;
  }
  const seed = {
    defaultProfile: DEFAULT_PROFILE,
    profiles: {
      [DEFAULT_PROFILE]: {
        createdAt: nowIso(),
        updatedAt: nowIso()
      }
    }
  };
  await writeJsonFile(PROFILES_FILE, seed);
  await ensureDir(getProfileDir(DEFAULT_PROFILE));
  return seed;
}
async function saveProfilesDb(db) {
  await writeJsonFile(PROFILES_FILE, db);
}
async function listProfiles() {
  return ensureProfilesDb();
}
async function ensureProfile(name) {
  assertProfileName(name);
  const db = await ensureProfilesDb();
  if (!db.profiles[name]) {
    db.profiles[name] = {
      createdAt: nowIso(),
      updatedAt: nowIso()
    };
    await saveProfilesDb(db);
  }
  await ensureDir(getProfileDir(name));
}
async function addProfile(name) {
  assertProfileName(name);
  const db = await ensureProfilesDb();
  if (db.profiles[name]) {
    throw new Error(`Profile "${name}" already exists.`);
  }
  db.profiles[name] = {
    createdAt: nowIso(),
    updatedAt: nowIso()
  };
  await saveProfilesDb(db);
  await ensureDir(getProfileDir(name));
}
async function setDefaultProfile(name) {
  const db = await ensureProfilesDb();
  if (!db.profiles[name]) {
    throw new Error(`Profile "${name}" does not exist.`);
  }
  db.defaultProfile = name;
  db.profiles[name].updatedAt = nowIso();
  await saveProfilesDb(db);
}
async function setProfileLabel(name, label) {
  const db = await ensureProfilesDb();
  if (!db.profiles[name]) {
    throw new Error(`Profile "${name}" does not exist.`);
  }
  db.profiles[name].label = label;
  db.profiles[name].updatedAt = nowIso();
  await saveProfilesDb(db);
}
async function removeProfile(name) {
  const db = await ensureProfilesDb();
  if (!db.profiles[name]) {
    throw new Error(`Profile "${name}" does not exist.`);
  }
  delete db.profiles[name];
  const profileNames = Object.keys(db.profiles);
  if (profileNames.length === 0) {
    db.profiles[DEFAULT_PROFILE] = {
      createdAt: nowIso(),
      updatedAt: nowIso()
    };
    db.defaultProfile = DEFAULT_PROFILE;
    await ensureDir(getProfileDir(DEFAULT_PROFILE));
  } else if (!db.profiles[db.defaultProfile]) {
    db.defaultProfile = profileNames[0];
  }
  await saveProfilesDb(db);
  await fs.rm(getProfileDir(name), { recursive: true, force: true });
}
async function resolveProfileName(flagProfile) {
  const db = await ensureProfilesDb();
  const picked = flagProfile && flagProfile.trim() || (process.env.OPENZCA_PROFILE?.trim() || process.env.ZCA_PROFILE?.trim()) || db.defaultProfile || DEFAULT_PROFILE;
  if (!db.profiles[picked]) {
    if (picked === DEFAULT_PROFILE) {
      await ensureProfile(DEFAULT_PROFILE);
      return DEFAULT_PROFILE;
    }
    throw new Error(
      `Profile "${picked}" does not exist. Create it with: account add ${picked}`
    );
  }
  return picked;
}
async function loadCredentials(profileName) {
  return readJsonFile(getCredentialsPath(profileName));
}
async function saveCredentials(profileName, credentials) {
  await ensureProfile(profileName);
  await writeJsonFile(getCredentialsPath(profileName), credentials);
}
async function clearCredentials(profileName) {
  await fs.rm(getCredentialsPath(profileName), { force: true });
}
async function writeCache(profileName, payload) {
  await ensureProfile(profileName);
  await ensureDir(getCacheDir(profileName));
  await writeJsonFile(getFriendsCachePath(profileName), payload.friends);
  await writeJsonFile(getGroupsCachePath(profileName), payload.groups);
  await writeJsonFile(getCacheMetaPath(profileName), { updatedAt: payload.updatedAt });
}
async function readCache(profileName) {
  const friends = await readJsonFile(getFriendsCachePath(profileName)) ?? [];
  const groups = await readJsonFile(getGroupsCachePath(profileName)) ?? [];
  const meta = await readJsonFile(getCacheMetaPath(profileName)) ?? null;
  return {
    friends,
    groups,
    updatedAt: meta?.updatedAt ?? null
  };
}
async function clearCache(profileName) {
  await fs.rm(getFriendsCachePath(profileName), { force: true });
  await fs.rm(getGroupsCachePath(profileName), { force: true });
  await fs.rm(getCacheMetaPath(profileName), { force: true });
}

// src/lib/db.ts
import crypto from "crypto";
import fs2 from "fs/promises";
import { createRequire } from "module";
import path2 from "path";
import { Worker } from "worker_threads";
var require2 = createRequire(import.meta.url);
function buildDbError(error) {
  const built = new Error(error.message);
  built.name = error.name || "Error";
  built.stack = error.stack ?? built.stack;
  built.code = error.code;
  return built;
}
function resolveWorkerSpec() {
  const currentUrl = new URL(import.meta.url);
  if (currentUrl.pathname.endsWith("/src/lib/db.ts")) {
    return {
      url: new URL("./db-worker.ts", currentUrl),
      execArgv: ["--import", require2.resolve("tsx")]
    };
  }
  return {
    url: new URL("./db-worker.js", currentUrl)
  };
}
var Database = class _Database {
  #worker;
  #closed = false;
  #closing = false;
  #released = false;
  #nextId = 1;
  #activeRequests = 0;
  #idleTimer;
  #closePromise;
  #pending = /* @__PURE__ */ new Map();
  #ready;
  #exited;
  #releaseConnection;
  constructor(worker, releaseConnection) {
    this.#worker = worker;
    this.#releaseConnection = releaseConnection;
    let resolveReady;
    let rejectReady;
    this.#ready = new Promise((resolve, reject) => {
      resolveReady = resolve;
      rejectReady = reject;
    });
    let resolveExited;
    this.#exited = new Promise((resolve) => {
      resolveExited = resolve;
    });
    const rejectPending = (error) => {
      for (const { reject } of this.#pending.values()) {
        reject(error);
      }
      this.#pending.clear();
    };
    worker.on("message", (message) => {
      if (message.type === "ready") {
        resolveReady();
        return;
      }
      if (message.type === "fatal") {
        const error = buildDbError(message.error);
        rejectReady(error);
        rejectPending(error);
        return;
      }
      const pending = this.#pending.get(message.id);
      if (!pending) {
        return;
      }
      this.#pending.delete(message.id);
      if (message.type === "error") {
        pending.reject(buildDbError(message.error));
        return;
      }
      pending.resolve(message.result);
    });
    worker.once("error", (error) => {
      rejectReady(error);
      rejectPending(error instanceof Error ? error : new Error(String(error)));
    });
    worker.once("exit", (code) => {
      this.#closed = true;
      this.#release();
      resolveExited();
      const error = new Error(
        code === 0 ? "DB worker exited" : `DB worker exited with code ${code}`
      );
      if (code !== 0) {
        rejectReady(error);
      }
      if (this.#pending.size > 0) {
        rejectPending(error);
      }
    });
  }
  static async open(filename, onClosed) {
    const { url, execArgv } = resolveWorkerSpec();
    const worker = new Worker(url, {
      execArgv,
      workerData: { filename }
    });
    worker.unref();
    const db = new _Database(worker, onClosed);
    await db.#ready;
    return db;
  }
  get isClosing() {
    return this.#closing || this.#closed;
  }
  #release() {
    if (this.#released) {
      return;
    }
    this.#released = true;
    this.#releaseConnection();
  }
  #clearIdleClose() {
    if (this.#idleTimer) {
      clearTimeout(this.#idleTimer);
      this.#idleTimer = void 0;
    }
  }
  #scheduleIdleClose() {
    this.#clearIdleClose();
    this.#idleTimer = setTimeout(() => {
      if (this.#activeRequests === 0 && !this.#closed) {
        void this.close().catch(() => {
        });
      }
    }, 100);
    this.#idleTimer.unref();
  }
  async #request(type, payload) {
    if (this.#closed) {
      throw new Error("DB worker is closed");
    }
    if (this.#closing && type !== "close") {
      throw new Error("DB worker is closing");
    }
    this.#clearIdleClose();
    this.#activeRequests += 1;
    await this.#ready;
    const id = this.#nextId;
    this.#nextId += 1;
    const request = payload === void 0 ? { id, type } : { id, type, payload };
    const result = new Promise((resolve, reject) => {
      this.#pending.set(id, { resolve, reject });
    });
    try {
      this.#worker.postMessage(request);
    } catch (error) {
      this.#pending.delete(id);
      throw error;
    }
    try {
      return await result;
    } finally {
      this.#activeRequests -= 1;
      if (this.#activeRequests === 0 && !this.#closed && !this.#closing) {
        this.#scheduleIdleClose();
      }
    }
  }
  async exec(sql) {
    await this.#request("exec", { sql });
  }
  async run(sql, params = []) {
    return await this.#request("run", { sql, params });
  }
  async get(sql, params = []) {
    const result = await this.#request("get", { sql, params });
    return result ?? void 0;
  }
  async all(sql, params = []) {
    return await this.#request("all", { sql, params });
  }
  async batch(commands, transactional = false) {
    await this.#request("batch", { commands, transactional });
  }
  async close() {
    if (this.#closed) {
      return;
    }
    if (this.#closePromise) {
      return this.#closePromise;
    }
    this.#closePromise = (async () => {
      this.#closing = true;
      this.#release();
      this.#clearIdleClose();
      await this.#request("close");
      this.#closed = true;
      await this.#exited;
    })();
    return this.#closePromise;
  }
};
var DB_CONFIG_FILE = "db.json";
var DB_FILENAME = "messages.sqlite";
var connections = /* @__PURE__ */ new Map();
var writeQueues = /* @__PURE__ */ new Map();
var UPSERT_THREAD_SQL = `
  INSERT INTO threads (
    profile, scope_thread_id, raw_thread_id, thread_type, peer_id, title,
    is_pinned, is_hidden, is_archived, raw_json, created_at, updated_at
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  ON CONFLICT(profile, scope_thread_id) DO UPDATE SET
    raw_thread_id = excluded.raw_thread_id,
    thread_type = excluded.thread_type,
    peer_id = COALESCE(excluded.peer_id, threads.peer_id),
    title = COALESCE(excluded.title, threads.title),
    is_pinned = excluded.is_pinned,
    is_hidden = excluded.is_hidden,
    is_archived = excluded.is_archived,
    raw_json = COALESCE(excluded.raw_json, threads.raw_json),
    updated_at = excluded.updated_at
`;
var INSERT_THREAD_MEMBER_SQL = `
  INSERT INTO thread_members (
    profile, scope_thread_id, user_id, display_name, zalo_name, avatar,
    account_status, member_type, raw_json, snapshot_at_ms, created_at, updated_at
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
var UPSERT_CONTACT_SQL = `
  INSERT INTO contacts (
    profile, user_id, display_name, zalo_name, avatar, account_status,
    relationship, first_seen_at_ms, last_seen_at_ms, raw_json, created_at, updated_at
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  ON CONFLICT(profile, user_id) DO UPDATE SET
    display_name = COALESCE(excluded.display_name, contacts.display_name),
    zalo_name = COALESCE(excluded.zalo_name, contacts.zalo_name),
    avatar = COALESCE(excluded.avatar, contacts.avatar),
    account_status = COALESCE(excluded.account_status, contacts.account_status),
    relationship = CASE
      WHEN contacts.relationship = 'friend' OR excluded.relationship = 'friend' THEN 'friend'
      WHEN contacts.relationship = 'seen_dm' OR excluded.relationship = 'seen_dm' THEN 'seen_dm'
      WHEN contacts.relationship = 'seen_group' OR excluded.relationship = 'seen_group' THEN 'seen_group'
      ELSE COALESCE(excluded.relationship, contacts.relationship, 'unknown')
    END,
    first_seen_at_ms = CASE
      WHEN contacts.first_seen_at_ms IS NULL THEN excluded.first_seen_at_ms
      WHEN excluded.first_seen_at_ms IS NULL THEN contacts.first_seen_at_ms
      ELSE MIN(contacts.first_seen_at_ms, excluded.first_seen_at_ms)
    END,
    last_seen_at_ms = CASE
      WHEN contacts.last_seen_at_ms IS NULL THEN excluded.last_seen_at_ms
      WHEN excluded.last_seen_at_ms IS NULL THEN contacts.last_seen_at_ms
      ELSE MAX(contacts.last_seen_at_ms, excluded.last_seen_at_ms)
    END,
    raw_json = COALESCE(excluded.raw_json, contacts.raw_json),
    updated_at = excluded.updated_at
`;
var UPSERT_MESSAGE_SQL = `
  INSERT INTO messages (
    profile, message_uid, scope_thread_id, raw_thread_id, thread_type,
    msg_id, cli_msg_id, action_id, sender_id, sender_name, to_id,
    timestamp_ms, msg_type, content_text, content_json,
    quote_msg_id, quote_cli_msg_id, quote_owner_id, quote_text,
    source, raw_message_json, raw_payload_json, created_at, updated_at
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  ON CONFLICT(profile, message_uid) DO UPDATE SET
    scope_thread_id = excluded.scope_thread_id,
    raw_thread_id = excluded.raw_thread_id,
    thread_type = excluded.thread_type,
    msg_id = COALESCE(excluded.msg_id, messages.msg_id),
    cli_msg_id = COALESCE(excluded.cli_msg_id, messages.cli_msg_id),
    action_id = COALESCE(excluded.action_id, messages.action_id),
    sender_id = COALESCE(excluded.sender_id, messages.sender_id),
    sender_name = COALESCE(excluded.sender_name, messages.sender_name),
    to_id = COALESCE(excluded.to_id, messages.to_id),
    timestamp_ms = excluded.timestamp_ms,
    msg_type = COALESCE(excluded.msg_type, messages.msg_type),
    content_text = COALESCE(excluded.content_text, messages.content_text),
    content_json = COALESCE(excluded.content_json, messages.content_json),
    quote_msg_id = COALESCE(excluded.quote_msg_id, messages.quote_msg_id),
    quote_cli_msg_id = COALESCE(excluded.quote_cli_msg_id, messages.quote_cli_msg_id),
    quote_owner_id = COALESCE(excluded.quote_owner_id, messages.quote_owner_id),
    quote_text = COALESCE(excluded.quote_text, messages.quote_text),
    source = excluded.source,
    raw_message_json = COALESCE(excluded.raw_message_json, messages.raw_message_json),
    raw_payload_json = COALESCE(excluded.raw_payload_json, messages.raw_payload_json),
    updated_at = excluded.updated_at
`;
var INSERT_MESSAGE_MEDIA_SQL = `
  INSERT INTO message_media (
    profile, message_uid, item_index, media_kind, media_url,
    media_path, media_type, raw_json, created_at, updated_at
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
var INSERT_MESSAGE_MENTION_SQL = `
  INSERT INTO message_mentions (
    profile, message_uid, item_index, target_user_id, pos, len,
    mention_type, raw_json, created_at, updated_at
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
function nowIso2() {
  return (/* @__PURE__ */ new Date()).toISOString();
}
function normalizeId(value) {
  if (typeof value === "number" && Number.isFinite(value)) {
    return String(Math.trunc(value));
  }
  if (typeof value !== "string") {
    return "";
  }
  return value.trim();
}
function normalizeOptionalText(value) {
  if (typeof value !== "string") {
    return void 0;
  }
  const trimmed = value.trim();
  return trimmed || void 0;
}
function normalizeRelationship(value) {
  if (value !== "friend" && value !== "seen_dm" && value !== "seen_group" && value !== "unknown") {
    return void 0;
  }
  return value;
}
function normalizeSearchText(value) {
  return value.normalize("NFD").replace(new RegExp("\\p{Diacritic}", "gu"), "").toLowerCase().trim();
}
function globToRegex(pattern) {
  const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
  const regexSource = escaped.replace(/\*/g, ".*").replace(/\?/g, ".");
  return new RegExp(`^${regexSource}$`);
}
function matchesSearchPattern(value, query) {
  const normalizedValue = normalizeSearchText(value);
  if (query.includes("*") || query.includes("?")) {
    return globToRegex(query).test(normalizedValue);
  }
  return normalizedValue.includes(query);
}
function safeJsonStringify(value) {
  if (value === void 0) {
    return void 0;
  }
  return JSON.stringify(value);
}
function defaultDbPath(profile) {
  return path2.join(getProfileDir(profile), DB_FILENAME);
}
function getDbConfigPath(profile) {
  return path2.join(getProfileDir(profile), DB_CONFIG_FILE);
}
async function readDbConfig(profile) {
  const configPath = getDbConfigPath(profile);
  try {
    const raw = await fs2.readFile(configPath, "utf8");
    const parsed = JSON.parse(raw);
    return {
      enabled: Boolean(parsed.enabled),
      path: normalizeOptionalText(parsed.path),
      updatedAt: normalizeOptionalText(parsed.updatedAt) ?? nowIso2()
    };
  } catch (error) {
    const code = error.code;
    if (code === "ENOENT") {
      return {
        enabled: false,
        updatedAt: nowIso2()
      };
    }
    throw error;
  }
}
async function writeDbConfig(profile, config) {
  const configPath = getDbConfigPath(profile);
  await fs2.mkdir(path2.dirname(configPath), { recursive: true });
  await fs2.writeFile(configPath, `${JSON.stringify(config, null, 2)}
`, "utf8");
}
async function enableDb(profile, customPath) {
  const config = {
    enabled: true,
    path: normalizeOptionalText(customPath),
    updatedAt: nowIso2()
  };
  await writeDbConfig(profile, config);
  await getDb(profile);
  return config;
}
async function disableDb(profile) {
  const existing = await readDbConfig(profile);
  const config = {
    enabled: false,
    path: existing.path,
    updatedAt: nowIso2()
  };
  await writeDbConfig(profile, config);
  return config;
}
async function isDbEnabled(profile) {
  const config = await readDbConfig(profile);
  return config.enabled;
}
async function resolveDbPath(profile) {
  const config = await readDbConfig(profile);
  const configured = normalizeOptionalText(config.path);
  if (!configured) {
    return defaultDbPath(profile);
  }
  return path2.isAbsolute(configured) ? configured : path2.resolve(getProfileDir(profile), configured);
}
async function openDb(profile) {
  const filename = await resolveDbPath(profile);
  await fs2.mkdir(path2.dirname(filename), { recursive: true });
  return Database.open(filename, () => {
    connections.delete(profile);
  });
}
async function getDb(profile) {
  const existing = connections.get(profile);
  if (existing) {
    const db = await existing;
    if (!db.isClosing) {
      return db;
    }
    connections.delete(profile);
  }
  const created = openDb(profile).catch((error) => {
    connections.delete(profile);
    throw error;
  });
  connections.set(profile, created);
  return created;
}
async function closeDb(profile) {
  const existing = connections.get(profile);
  if (!existing) {
    return;
  }
  connections.delete(profile);
  const db = await existing;
  await db.close();
}
function resolveDmPeerId(params) {
  const threadId = normalizeId(params.threadId);
  const senderId = normalizeId(params.senderId);
  const toId = normalizeId(params.toId);
  const selfId = normalizeId(params.selfId);
  if (selfId) {
    if (senderId === selfId && toId && toId !== selfId) {
      return toId;
    }
    if (toId === selfId && senderId && senderId !== selfId) {
      return senderId;
    }
    if (threadId && threadId !== selfId) {
      return threadId;
    }
    if (toId && toId !== selfId) {
      return toId;
    }
    if (senderId && senderId !== selfId) {
      return senderId;
    }
  }
  if (senderId && toId && senderId === threadId && toId !== senderId) {
    return toId;
  }
  if (senderId && toId && toId === threadId && senderId !== toId) {
    return senderId;
  }
  if (threadId) {
    return threadId;
  }
  if (toId && toId !== senderId) {
    return toId;
  }
  return senderId;
}
function resolveScopeThreadId(params) {
  if (params.threadType === "group") {
    return normalizeId(params.rawThreadId);
  }
  return resolveDmPeerId({
    threadId: params.rawThreadId,
    senderId: params.senderId,
    toId: params.toId,
    selfId: params.selfId
  });
}
function toMessageUid(record) {
  const scopeThreadId = normalizeId(record.scopeThreadId);
  const msgId = normalizeId(record.msgId);
  const cliMsgId = normalizeId(record.cliMsgId);
  const actionId = normalizeId(record.actionId);
  const timestamp = String(record.timestampMs || 0);
  if (msgId) {
    return `${scopeThreadId}:msg:${msgId}`;
  }
  if (cliMsgId) {
    return `${scopeThreadId}:cli:${cliMsgId}`;
  }
  if (actionId) {
    return `${scopeThreadId}:action:${actionId}`;
  }
  const hash = crypto.createHash("sha256").update(
    JSON.stringify({
      scopeThreadId,
      rawThreadId: record.rawThreadId,
      senderId: record.senderId,
      toId: record.toId,
      timestamp,
      msgType: record.msgType,
      contentText: record.contentText
    })
  ).digest("hex").slice(0, 24);
  return `${scopeThreadId}:hash:${hash}`;
}
async function withWriteQueue(profile, task) {
  const prior = writeQueues.get(profile) ?? Promise.resolve();
  const next = prior.catch(() => void 0).then(task);
  writeQueues.set(profile, next);
  try {
    await next;
  } finally {
    if (writeQueues.get(profile) === next) {
      writeQueues.delete(profile);
    }
  }
}
function enqueueDbWrite(profile, task) {
  void withWriteQueue(profile, task).catch(() => {
  });
}
async function persistThread(record) {
  const db = await getDb(record.profile);
  const now = nowIso2();
  await db.run(UPSERT_THREAD_SQL, [
    record.profile,
    record.scopeThreadId,
    record.rawThreadId,
    record.threadType,
    record.peerId ?? null,
    record.title ?? null,
    record.isPinned ? 1 : 0,
    record.isHidden ? 1 : 0,
    record.isArchived ? 1 : 0,
    record.rawJson ?? null,
    now,
    now
  ]);
}
async function replaceThreadMembers(profile, scopeThreadId, members) {
  const db = await getDb(profile);
  const now = nowIso2();
  const commands = [
    {
      sql: `DELETE FROM thread_members WHERE profile = ? AND scope_thread_id = ?`,
      params: [profile, scopeThreadId]
    },
    ...members.map((member) => ({
      sql: INSERT_THREAD_MEMBER_SQL,
      params: [
        member.profile,
        member.scopeThreadId,
        member.userId,
        member.displayName ?? null,
        member.zaloName ?? null,
        member.avatar ?? null,
        member.accountStatus ?? null,
        member.memberType ?? null,
        member.rawJson ?? null,
        member.snapshotAtMs,
        now,
        now
      ]
    }))
  ];
  await db.batch(commands, true);
}
async function persistContact(record) {
  const db = await getDb(record.profile);
  const now = nowIso2();
  await db.run(UPSERT_CONTACT_SQL, [
    record.profile,
    record.userId,
    record.displayName ?? null,
    record.zaloName ?? null,
    record.avatar ?? null,
    record.accountStatus ?? null,
    normalizeRelationship(record.relationship) ?? "unknown",
    record.firstSeenAtMs ?? null,
    record.lastSeenAtMs ?? null,
    record.rawJson ?? null,
    now,
    now
  ]);
}
async function persistSelfProfile(params) {
  const db = await getDb(params.profile);
  const now = nowIso2();
  await db.run(
    `
      INSERT INTO self_profiles (
        profile, user_id, display_name, info_json, created_at, updated_at
      ) VALUES (?, ?, ?, ?, ?, ?)
      ON CONFLICT(profile) DO UPDATE SET
        user_id = excluded.user_id,
        display_name = COALESCE(excluded.display_name, self_profiles.display_name),
        info_json = COALESCE(excluded.info_json, self_profiles.info_json),
        updated_at = excluded.updated_at
    `,
    [
      params.profile,
      params.userId,
      params.displayName ?? null,
      params.infoJson ?? null,
      now,
      now
    ]
  );
}
async function persistMessage(record) {
  const db = await getDb(record.profile);
  const now = nowIso2();
  const messageUid = toMessageUid(record);
  const commands = [
    {
      sql: UPSERT_THREAD_SQL,
      params: [
        record.profile,
        record.scopeThreadId,
        record.rawThreadId,
        record.threadType,
        record.peerId ?? null,
        record.title ?? null,
        0,
        0,
        0,
        null,
        now,
        now
      ]
    },
    {
      sql: UPSERT_MESSAGE_SQL,
      params: [
        record.profile,
        messageUid,
        record.scopeThreadId,
        record.rawThreadId,
        record.threadType,
        record.msgId ?? null,
        record.cliMsgId ?? null,
        record.actionId ?? null,
        record.senderId ?? null,
        record.senderName ?? null,
        record.toId ?? null,
        record.timestampMs,
        record.msgType ?? null,
        record.contentText ?? null,
        record.contentJson ?? null,
        record.quoteMsgId ?? null,
        record.quoteCliMsgId ?? null,
        record.quoteOwnerId ?? null,
        record.quoteText ?? null,
        record.source,
        record.rawMessageJson ?? null,
        record.rawPayloadJson ?? null,
        now,
        now
      ]
    },
    {
      sql: `DELETE FROM message_media WHERE profile = ? AND message_uid = ?`,
      params: [record.profile, messageUid]
    },
    {
      sql: `DELETE FROM message_mentions WHERE profile = ? AND message_uid = ?`,
      params: [record.profile, messageUid]
    },
    ...(record.media ?? []).map((media, index) => ({
      sql: INSERT_MESSAGE_MEDIA_SQL,
      params: [
        record.profile,
        messageUid,
        index,
        media.mediaKind ?? null,
        media.mediaUrl ?? null,
        media.mediaPath ?? null,
        media.mediaType ?? null,
        media.rawJson ?? null,
        now,
        now
      ]
    })),
    ...(record.mentions ?? []).map((mention, index) => ({
      sql: INSERT_MESSAGE_MENTION_SQL,
      params: [
        record.profile,
        messageUid,
        index,
        mention.uid,
        mention.pos ?? null,
        mention.len ?? null,
        mention.type ?? null,
        mention.rawJson ?? null,
        now,
        now
      ]
    }))
  ];
  await db.batch(commands, true);
}
async function setSyncState(params) {
  const db = await getDb(params.profile);
  const now = nowIso2();
  const scope = `${params.threadType}:${params.scopeThreadId}`;
  await db.run(
    `
      INSERT INTO sync_state (
        profile, scope, scope_thread_id, thread_type, status, completeness,
        cursor, last_sync_at, error, created_at, updated_at
      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
      ON CONFLICT(profile, scope) DO UPDATE SET
        status = excluded.status,
        completeness = excluded.completeness,
        cursor = COALESCE(excluded.cursor, sync_state.cursor),
        last_sync_at = excluded.last_sync_at,
        error = excluded.error,
        updated_at = excluded.updated_at
    `,
    [
      params.profile,
      scope,
      params.scopeThreadId,
      params.threadType,
      params.status,
      params.completeness ?? null,
      params.cursor ?? null,
      now,
      params.error ?? null,
      now,
      now
    ]
  );
}
async function listRecentMessages(params) {
  const db = await getDb(params.profile);
  const rows = await db.all(
    `
      SELECT
        msg_id,
        cli_msg_id,
        scope_thread_id,
        thread_type,
        sender_id,
        sender_name,
        timestamp_ms,
        msg_type,
        content_text,
        content_json
      FROM messages
      WHERE profile = ? AND scope_thread_id = ? AND thread_type = ?
      ORDER BY timestamp_ms DESC, COALESCE(msg_id, ''), COALESCE(cli_msg_id, '')
      LIMIT ?
    `,
    [params.profile, params.threadId, params.threadType, params.count]
  );
  return rows.map((row) => ({
    msgId: row.msg_id ?? "",
    cliMsgId: row.cli_msg_id ?? "",
    threadId: row.scope_thread_id,
    threadType: row.thread_type,
    senderId: row.sender_id ?? "",
    senderName: row.sender_name ?? "",
    ts: String(row.timestamp_ms),
    msgType: row.msg_type ?? "",
    undo: {
      msgId: row.msg_id ?? "",
      cliMsgId: row.cli_msg_id ?? "",
      threadId: row.scope_thread_id,
      group: row.thread_type === "group"
    },
    content: row.content_text ?? row.content_json ?? ""
  }));
}
async function listMessages(params) {
  const db = await getDb(params.profile);
  const order = params.newestFirst ? "DESC" : "ASC";
  const limit = Number.isFinite(params.limit) ? Math.max(Math.trunc(params.limit), 1) : null;
  const rows = await db.all(
    `
      SELECT
        m.raw_thread_id,
        m.thread_type,
        m.msg_id,
        m.cli_msg_id,
        m.sender_id,
        COALESCE(
          NULLIF(m.sender_name, ''),
          NULLIF(tm.display_name, ''),
          NULLIF(tm.zalo_name, ''),
          NULLIF(c.display_name, ''),
          NULLIF(c.zalo_name, '')
        ) AS sender_name,
        m.to_id,
        m.timestamp_ms,
        m.msg_type,
        m.content_text,
        m.content_json,
        m.quote_msg_id,
        m.quote_cli_msg_id,
        m.quote_owner_id,
        m.quote_text,
        m.source
      FROM messages m
      LEFT JOIN thread_members tm
        ON tm.profile = m.profile
        AND tm.scope_thread_id = m.scope_thread_id
        AND tm.user_id = m.sender_id
      LEFT JOIN contacts c
        ON c.profile = m.profile
        AND c.user_id = m.sender_id
      WHERE m.profile = ?
        AND m.scope_thread_id = ?
        AND m.thread_type = ?
        AND (? IS NULL OR timestamp_ms >= ?)
        AND (? IS NULL OR timestamp_ms < ?)
      ORDER BY m.timestamp_ms ${order}, COALESCE(m.msg_id, ''), COALESCE(m.cli_msg_id, '')
      ${limit ? "LIMIT ?" : ""}
    `,
    limit ? [
      params.profile,
      params.threadId,
      params.threadType,
      params.sinceMs ?? null,
      params.sinceMs ?? null,
      params.untilMs ?? null,
      params.untilMs ?? null,
      limit
    ] : [
      params.profile,
      params.threadId,
      params.threadType,
      params.sinceMs ?? null,
      params.sinceMs ?? null,
      params.untilMs ?? null,
      params.untilMs ?? null
    ]
  );
  return rows.map((row) => ({
    msgId: row.msg_id ?? "",
    cliMsgId: row.cli_msg_id ?? "",
    threadId: params.threadId,
    threadType: row.thread_type,
    senderId: row.sender_id ?? "",
    senderName: row.sender_name ?? "",
    ts: String(row.timestamp_ms),
    timestampMs: row.timestamp_ms,
    msgType: row.msg_type ?? "",
    undo: {
      msgId: row.msg_id ?? "",
      cliMsgId: row.cli_msg_id ?? "",
      threadId: params.threadId,
      group: row.thread_type === "group"
    },
    content: row.content_text ?? row.content_json ?? "",
    rawThreadId: row.raw_thread_id,
    toId: row.to_id ?? void 0,
    quoteMsgId: row.quote_msg_id ?? void 0,
    quoteCliMsgId: row.quote_cli_msg_id ?? void 0,
    quoteOwnerId: row.quote_owner_id ?? void 0,
    quoteText: row.quote_text ?? void 0,
    source: row.source
  }));
}
async function getMessageById(params) {
  const db = await getDb(params.profile);
  const row = await db.get(
    `
      SELECT *
      FROM messages
      WHERE profile = ?
        AND (
          msg_id = ?
          OR cli_msg_id = ?
          OR message_uid = ?
        )
      ORDER BY timestamp_ms DESC
      LIMIT 1
    `,
    [params.profile, params.id, params.id, params.id]
  );
  if (!row) {
    return null;
  }
  return {
    threadId: row.scope_thread_id,
    rawThreadId: row.raw_thread_id,
    threadType: row.thread_type,
    msgId: row.msg_id ?? void 0,
    cliMsgId: row.cli_msg_id ?? void 0,
    actionId: row.action_id ?? void 0,
    senderId: row.sender_id ?? void 0,
    senderName: row.sender_name ?? void 0,
    toId: row.to_id ?? void 0,
    timestampMs: row.timestamp_ms,
    msgType: row.msg_type ?? void 0,
    content: row.content_text ?? void 0,
    contentJson: row.content_json ?? void 0,
    quoteMsgId: row.quote_msg_id ?? void 0,
    quoteCliMsgId: row.quote_cli_msg_id ?? void 0,
    quoteOwnerId: row.quote_owner_id ?? void 0,
    quoteText: row.quote_text ?? void 0,
    source: row.source,
    rawMessage: row.raw_message_json ? JSON.parse(row.raw_message_json) : void 0,
    rawPayload: row.raw_payload_json ? JSON.parse(row.raw_payload_json) : void 0
  };
}
async function listThreads(params) {
  const db = await getDb(params.profile);
  const rows = await db.all(
    `
      SELECT
        t.scope_thread_id,
        t.raw_thread_id,
        t.thread_type,
        t.title,
        t.peer_id,
        t.is_pinned,
        t.is_hidden,
        t.is_archived,
        COUNT(m.message_uid) AS message_count,
        MIN(m.timestamp_ms) AS first_message_at_ms,
        MAX(m.timestamp_ms) AS last_message_at_ms,
        (
          SELECT COUNT(*)
          FROM thread_members tm
          WHERE tm.profile = t.profile AND tm.scope_thread_id = t.scope_thread_id
        ) AS member_count
      FROM threads t
      LEFT JOIN messages m
        ON m.profile = t.profile AND m.scope_thread_id = t.scope_thread_id
      WHERE t.profile = ?
        AND (? IS NULL OR t.thread_type = ?)
      GROUP BY
        t.scope_thread_id, t.raw_thread_id, t.thread_type, t.title, t.peer_id,
        t.is_pinned, t.is_hidden, t.is_archived
      ORDER BY COALESCE(MAX(m.timestamp_ms), 0) DESC, t.scope_thread_id
    `,
    [params.profile, params.threadType ?? null, params.threadType ?? null]
  );
  return rows.map((row) => ({
    threadId: row.scope_thread_id,
    rawThreadId: row.raw_thread_id,
    threadType: row.thread_type,
    title: row.title ?? void 0,
    peerId: row.peer_id ?? void 0,
    messageCount: row.message_count,
    firstMessageAtMs: row.first_message_at_ms ?? void 0,
    lastMessageAtMs: row.last_message_at_ms ?? void 0,
    memberCount: row.member_count,
    isPinned: row.is_pinned === 1,
    isHidden: row.is_hidden === 1,
    isArchived: row.is_archived === 1
  }));
}
async function listGroups(profile) {
  return listThreads({ profile, threadType: "group" });
}
async function getThreadInfo(params) {
  const db = await getDb(params.profile);
  const row = await db.get(
    `
      SELECT
        t.scope_thread_id,
        t.raw_thread_id,
        t.thread_type,
        t.title,
        t.peer_id,
        t.is_pinned,
        t.is_hidden,
        t.is_archived,
        t.raw_json,
        COUNT(m.message_uid) AS message_count,
        MIN(m.timestamp_ms) AS first_message_at_ms,
        MAX(m.timestamp_ms) AS last_message_at_ms,
        (
          SELECT COUNT(*)
          FROM thread_members tm
          WHERE tm.profile = t.profile AND tm.scope_thread_id = t.scope_thread_id
        ) AS member_count
      FROM threads t
      LEFT JOIN messages m
        ON m.profile = t.profile AND m.scope_thread_id = t.scope_thread_id
      WHERE t.profile = ?
        AND (? IS NULL OR t.thread_type = ?)
        AND (t.scope_thread_id = ? OR t.raw_thread_id = ?)
      GROUP BY
        t.scope_thread_id, t.raw_thread_id, t.thread_type, t.title, t.peer_id,
        t.is_pinned, t.is_hidden, t.is_archived, t.raw_json
      ORDER BY
        CASE WHEN t.scope_thread_id = ? THEN 0 ELSE 1 END,
        COALESCE(MAX(m.timestamp_ms), 0) DESC
      LIMIT 1
    `,
    [
      params.profile,
      params.threadType ?? null,
      params.threadType ?? null,
      params.threadId,
      params.threadId,
      params.threadId
    ]
  );
  if (!row) {
    return null;
  }
  return {
    threadId: row.scope_thread_id,
    rawThreadId: row.raw_thread_id,
    threadType: row.thread_type,
    title: row.title ?? void 0,
    peerId: row.peer_id ?? void 0,
    messageCount: row.message_count,
    firstMessageAtMs: row.first_message_at_ms ?? void 0,
    lastMessageAtMs: row.last_message_at_ms ?? void 0,
    memberCount: row.member_count,
    isPinned: row.is_pinned === 1,
    isHidden: row.is_hidden === 1,
    isArchived: row.is_archived === 1,
    raw: row.raw_json ? JSON.parse(row.raw_json) : void 0
  };
}
var CONTACT_CHAT_CTE = `
  WITH ranked_contact_threads AS (
    SELECT
      c.profile AS contact_profile,
      c.user_id,
      t.scope_thread_id,
      t.title,
      COUNT(m.message_uid) AS message_count,
      MAX(m.timestamp_ms) AS last_message_at_ms,
      ROW_NUMBER() OVER (
        PARTITION BY c.profile, c.user_id
        ORDER BY
          COALESCE(MAX(m.timestamp_ms), 0) DESC,
          CASE
            WHEN t.scope_thread_id = c.user_id THEN 0
            WHEN t.peer_id = c.user_id THEN 1
            WHEN t.raw_thread_id = c.user_id THEN 2
            ELSE 3
          END,
          t.updated_at DESC,
          t.scope_thread_id
      ) AS thread_rank
    FROM contacts c
    LEFT JOIN threads t
      ON t.profile = c.profile
      AND t.thread_type = 'user'
      AND (t.peer_id = c.user_id OR t.scope_thread_id = c.user_id OR t.raw_thread_id = c.user_id)
    LEFT JOIN messages m
      ON m.profile = t.profile
      AND m.scope_thread_id = t.scope_thread_id
    GROUP BY
      c.profile,
      c.user_id,
      t.scope_thread_id,
      t.title,
      t.updated_at,
      t.peer_id,
      t.raw_thread_id
  )
`;
async function listContacts(params) {
  const db = await getDb(params.profile);
  const rows = await db.all(
    `
      ${CONTACT_CHAT_CTE}
      SELECT
        c.user_id,
        c.display_name,
        c.zalo_name,
        c.avatar,
        c.account_status,
        c.relationship,
        c.first_seen_at_ms,
        c.last_seen_at_ms,
        r.scope_thread_id AS chat_id,
        r.title,
        COALESCE(r.message_count, 0) AS message_count,
        r.last_message_at_ms
      FROM contacts c
      LEFT JOIN ranked_contact_threads r
        ON r.contact_profile = c.profile
        AND r.user_id = c.user_id
        AND r.thread_rank = 1
      WHERE c.profile = ?
        AND (? IS NULL OR c.relationship = ?)
      ORDER BY COALESCE(c.display_name, c.zalo_name, c.user_id), c.user_id
    `,
    [params.profile, params.relationship ?? null, params.relationship ?? null]
  );
  return rows.map((row) => ({
    userId: row.user_id,
    displayName: row.display_name ?? void 0,
    zaloName: row.zalo_name ?? void 0,
    avatar: row.avatar ?? void 0,
    accountStatus: row.account_status ?? void 0,
    relationship: normalizeRelationship(row.relationship) ?? "unknown",
    firstSeenAtMs: row.first_seen_at_ms ?? void 0,
    lastSeenAtMs: row.last_seen_at_ms ?? void 0,
    title: row.title ?? void 0,
    chatId: row.chat_id ?? row.user_id,
    messageCount: row.message_count,
    lastMessageAtMs: row.last_message_at_ms ?? void 0
  }));
}
async function listFriends(profile) {
  return await listContacts({ profile, relationship: "friend" });
}
async function getContactInfo(params) {
  const db = await getDb(params.profile);
  const row = await db.get(
    `
      ${CONTACT_CHAT_CTE}
      SELECT
        c.user_id,
        c.display_name,
        c.zalo_name,
        c.avatar,
        c.account_status,
        c.relationship,
        c.first_seen_at_ms,
        c.last_seen_at_ms,
        c.raw_json,
        r.scope_thread_id AS chat_id,
        r.title,
        COALESCE(r.message_count, 0) AS message_count,
        r.last_message_at_ms
      FROM contacts c
      LEFT JOIN ranked_contact_threads r
        ON r.contact_profile = c.profile
        AND r.user_id = c.user_id
        AND r.thread_rank = 1
      WHERE c.profile = ?
        AND c.user_id = ?
        AND (? IS NULL OR c.relationship = ?)
      LIMIT 1
    `,
    [
      params.profile,
      params.userId,
      params.relationship ?? null,
      params.relationship ?? null
    ]
  );
  if (!row) {
    return null;
  }
  return {
    userId: row.user_id,
    displayName: row.display_name ?? void 0,
    zaloName: row.zalo_name ?? void 0,
    avatar: row.avatar ?? void 0,
    accountStatus: row.account_status ?? void 0,
    relationship: normalizeRelationship(row.relationship) ?? "unknown",
    firstSeenAtMs: row.first_seen_at_ms ?? void 0,
    lastSeenAtMs: row.last_seen_at_ms ?? void 0,
    title: row.title ?? void 0,
    chatId: row.chat_id ?? row.user_id,
    messageCount: row.message_count,
    lastMessageAtMs: row.last_message_at_ms ?? void 0,
    raw: row.raw_json ? JSON.parse(row.raw_json) : void 0
  };
}
async function getFriendInfo(params) {
  return await getContactInfo({
    profile: params.profile,
    userId: params.userId,
    relationship: "friend"
  });
}
async function findContacts(params) {
  const query = normalizeSearchText(params.query);
  if (!query) {
    return [];
  }
  const rows = await listContacts({
    profile: params.profile,
    relationship: params.relationship
  });
  return rows.filter((row) => {
    const haystacks = [
      row.userId,
      row.displayName ?? "",
      row.zaloName ?? "",
      row.title ?? ""
    ];
    return haystacks.some((value) => matchesSearchPattern(value, query));
  });
}
async function findFriends(params) {
  return await findContacts({
    profile: params.profile,
    query: params.query,
    relationship: "friend"
  });
}
async function reconcileFriendRelationships(params) {
  const db = await getDb(params.profile);
  const now = nowIso2();
  const friendIds = Array.from(
    new Set(params.currentFriendIds.map((value) => normalizeId(value)).filter(Boolean))
  );
  const stalePredicate = friendIds.length > 0 ? `AND contacts.user_id NOT IN (${friendIds.map(() => "?").join(", ")})` : "";
  const sqlParams = [now, params.profile, ...friendIds];
  await db.run(
    `
      UPDATE contacts
      SET relationship = CASE
        WHEN EXISTS (
          SELECT 1
          FROM threads t
          WHERE t.profile = contacts.profile
            AND t.thread_type = 'user'
            AND (
              t.peer_id = contacts.user_id
              OR t.scope_thread_id = contacts.user_id
              OR t.raw_thread_id = contacts.user_id
            )
        ) THEN 'seen_dm'
        WHEN EXISTS (
          SELECT 1
          FROM thread_members tm
          WHERE tm.profile = contacts.profile
            AND tm.user_id = contacts.user_id
        ) THEN 'seen_group'
        ELSE 'unknown'
      END,
      updated_at = ?
      WHERE contacts.profile = ?
        AND contacts.relationship = 'friend'
        ${stalePredicate}
    `,
    sqlParams
  );
}
async function listChats(profile) {
  return listThreads({ profile });
}
async function getSelfProfile(profile) {
  const db = await getDb(profile);
  const row = await db.get(
    `
      SELECT user_id, display_name, info_json
      FROM self_profiles
      WHERE profile = ?
      LIMIT 1
    `,
    [profile]
  );
  if (!row) {
    return null;
  }
  return {
    userId: row.user_id,
    displayName: row.display_name ?? void 0,
    info: row.info_json ? JSON.parse(row.info_json) : void 0
  };
}
async function listThreadMembers(params) {
  const db = await getDb(params.profile);
  const rows = await db.all(
    `
      SELECT
        user_id, display_name, zalo_name, avatar, account_status, member_type, snapshot_at_ms
      FROM thread_members
      WHERE profile = ? AND scope_thread_id = ?
      ORDER BY COALESCE(display_name, zalo_name, user_id), user_id
    `,
    [params.profile, params.threadId]
  );
  return rows.map((row) => ({
    userId: row.user_id,
    displayName: row.display_name ?? void 0,
    zaloName: row.zalo_name ?? void 0,
    avatar: row.avatar ?? void 0,
    accountStatus: row.account_status ?? void 0,
    type: row.member_type ?? void 0,
    snapshotAtMs: row.snapshot_at_ms
  }));
}
async function getDbStatus(profile) {
  const filename = await resolveDbPath(profile);
  const exists = await fs2.access(filename).then(() => true).catch(() => false);
  const config = await readDbConfig(profile);
  if (!exists) {
    return {
      enabled: config.enabled,
      path: filename,
      exists: false,
      messageCount: 0,
      threadCount: 0,
      groupCount: 0,
      userCount: 0,
      updatedAt: config.updatedAt
    };
  }
  const db = await getDb(profile);
  const row = await db.get(`
    SELECT
      (SELECT COUNT(*) FROM messages WHERE profile = ?) AS message_count,
      (SELECT COUNT(*) FROM threads WHERE profile = ?) AS thread_count,
      (SELECT COUNT(*) FROM threads WHERE profile = ? AND thread_type = 'group') AS group_count,
      (SELECT COUNT(*) FROM threads WHERE profile = ? AND thread_type = 'user') AS user_count,
      (SELECT MAX(timestamp_ms) FROM messages WHERE profile = ?) AS last_message_at_ms,
      (
        SELECT MAX(updated_at)
        FROM (
          SELECT updated_at FROM messages WHERE profile = ?
          UNION ALL
          SELECT updated_at FROM threads WHERE profile = ?
        )
      ) AS last_updated_at
  `, [profile, profile, profile, profile, profile, profile, profile]);
  return {
    enabled: config.enabled,
    path: filename,
    exists: true,
    messageCount: row?.message_count ?? 0,
    threadCount: row?.thread_count ?? 0,
    groupCount: row?.group_count ?? 0,
    userCount: row?.user_count ?? 0,
    lastMessageAtMs: row?.last_message_at_ms ?? void 0,
    updatedAt: row?.last_updated_at ?? config.updatedAt
  };
}
async function listSyncState(params) {
  const filename = await resolveDbPath(params.profile);
  const exists = await fs2.access(filename).then(() => true).catch(() => false);
  if (!exists) {
    return [];
  }
  const db = await getDb(params.profile);
  const rows = await db.all(
    `
      SELECT scope, scope_thread_id, thread_type, status, cursor, completeness, last_sync_at, error
      FROM sync_state
      WHERE profile = ? AND (? IS NULL OR thread_type = ?)
      ORDER BY COALESCE(last_sync_at, ''), scope
    `,
    [params.profile, params.threadType ?? null, params.threadType ?? null]
  );
  return rows.map((row) => ({
    scope: row.scope,
    scopeThreadId: row.scope_thread_id,
    threadType: row.thread_type,
    status: row.status,
    cursor: row.cursor ?? void 0,
    completeness: row.completeness ?? void 0,
    lastSyncAt: row.last_sync_at ?? void 0,
    error: row.error ?? void 0
  }));
}
function normalizeInboundListenRecord(params) {
  const scopeThreadId = resolveScopeThreadId({
    threadType: params.threadType,
    rawThreadId: params.rawThreadId,
    senderId: params.senderId,
    toId: params.toId,
    selfId: params.selfId
  });
  return {
    profile: params.profile,
    scopeThreadId,
    rawThreadId: params.rawThreadId,
    threadType: params.threadType,
    peerId: params.threadType === "user" ? scopeThreadId : void 0,
    title: params.title,
    msgId: normalizeOptionalText(params.msgId),
    cliMsgId: normalizeOptionalText(params.cliMsgId),
    actionId: normalizeOptionalText(params.actionId),
    senderId: normalizeOptionalText(params.senderId),
    senderName: normalizeOptionalText(params.senderName),
    toId: normalizeOptionalText(params.toId),
    timestampMs: params.timestampMs,
    msgType: normalizeOptionalText(params.msgType),
    contentText: params.contentText,
    contentJson: params.contentJson,
    quoteMsgId: normalizeOptionalText(params.quoteMsgId),
    quoteCliMsgId: normalizeOptionalText(params.quoteCliMsgId),
    quoteOwnerId: normalizeOptionalText(params.quoteOwnerId),
    quoteText: params.quoteText,
    media: params.media,
    mentions: params.mentions,
    source: params.source,
    rawMessageJson: safeJsonStringify(params.rawMessage),
    rawPayloadJson: safeJsonStringify(params.rawPayload)
  };
}

// src/lib/client.ts
import fs3 from "fs/promises";
import { spawn } from "child_process";
import path3 from "path";
import { imageSize } from "image-size";
import * as qrcodeTerminal from "qrcode-terminal";
import {
  LoginQRCallbackEventType,
  Zalo
} from "zca-js";
function renderInlineQrPngIfSupported(pngBase64, filePath) {
  if (!process.stdout.isTTY) return false;
  try {
    const termProgram = (process.env.TERM_PROGRAM ?? "").toLowerCase();
    const term = (process.env.TERM ?? "").toLowerCase();
    const renderMode = (process.env.OPENZCA_QR_RENDER ?? "auto").toLowerCase();
    const debug = process.env.OPENZCA_QR_DEBUG === "1";
    const shouldTryKitty = renderMode === "kitty" || renderMode === "auto" && (term.includes("ghostty") || term.includes("kitty") || termProgram.includes("ghostty") || termProgram.includes("wezterm"));
    const shouldTryIterm = renderMode === "iterm" || renderMode === "auto" && termProgram === "iterm.app" && process.env.OPENZCA_QR_DISABLE_ITERM_INLINE !== "1";
    if (shouldTryKitty) {
      if (debug) console.error("[openzca] QR render mode: kitty");
      const payload = pngBase64.replace(/\s+/g, "");
      if (payload.length === 0) return false;
      const chunkSize = 1024;
      for (let start = 0; start < payload.length; start += chunkSize) {
        const chunk = payload.slice(start, start + chunkSize);
        const hasMore = start + chunkSize < payload.length ? 1 : 0;
        const metadata = start === 0 ? "a=T,f=100," : "";
        const apc = `\x1B_G${metadata}m=${hasMore};${chunk}\x1B\\`;
        process.stdout.write(apc);
      }
      process.stdout.write("\n");
      return true;
    }
    if (shouldTryIterm) {
      if (debug) console.error("[openzca] QR render mode: iterm");
      const widthEnv = Number.parseInt(process.env.OPENZCA_QR_WIDTH ?? "", 10);
      const widthCells = Number.isFinite(widthEnv) && widthEnv >= 16 && widthEnv <= 80 ? widthEnv : 34;
      const encodedName = Buffer.from(path3.basename(filePath)).toString("base64");
      const osc1337 = `\x1B]1337;File=name=${encodedName};inline=1;width=${widthCells};preserveAspectRatio=1:${pngBase64}\x07`;
      process.stdout.write(`${osc1337}
`);
      return true;
    }
    if (debug) {
      console.error(
        `[openzca] QR render mode: ascii (TERM_PROGRAM=${termProgram || "-"}, TERM=${term || "-"}, OPENZCA_QR_RENDER=${renderMode})`
      );
    }
    return false;
  } catch {
    return false;
  }
}
function renderAsciiQrFromCode(code) {
  if (!process.stdout.isTTY) return false;
  if (!code || !code.trim()) return false;
  if (process.env.OPENZCA_QR_ASCII === "0") return false;
  try {
    qrcodeTerminal.generate(code, { small: true });
    return true;
  } catch {
    return false;
  }
}
function quoteForShell(value) {
  if (process.platform === "win32") {
    return `"${value.replace(/"/g, '\\"')}"`;
  }
  return `'${value.replace(/'/g, `'\\''`)}'`;
}
function formatOpenInstruction(filePath) {
  const quoted = quoteForShell(filePath);
  if (process.platform === "darwin") {
    return `open ${quoted}`;
  }
  if (process.platform === "win32") {
    return `start "" ${quoted}`;
  }
  return `xdg-open ${quoted}`;
}
function tryOpenFile(filePath) {
  try {
    if (process.platform === "darwin") {
      const proc2 = spawn("open", [filePath], {
        detached: true,
        stdio: "ignore"
      });
      proc2.unref();
      return true;
    }
    if (process.platform === "win32") {
      const proc2 = spawn("cmd", ["/c", "start", "", filePath], {
        detached: true,
        stdio: "ignore",
        windowsHide: true
      });
      proc2.unref();
      return true;
    }
    const proc = spawn("xdg-open", [filePath], {
      detached: true,
      stdio: "ignore"
    });
    proc.unref();
    return true;
  } catch {
    return false;
  }
}
async function imageMetadataGetter(filePath) {
  const data = await fs3.readFile(filePath);
  const info = imageSize(data);
  if (!info.width || !info.height) {
    throw new Error(`Cannot read image size: ${filePath}`);
  }
  return {
    width: info.width,
    height: info.height,
    size: data.length
  };
}
function createZaloClient(options) {
  return new Zalo({
    imageMetadataGetter,
    logging: false,
    ...options
  });
}
function toCredentials(value) {
  return {
    imei: value.imei,
    cookie: value.cookie,
    userAgent: value.userAgent,
    language: value.language
  };
}
async function loginWithStoredCredentials(profileName, options) {
  const stored = await loadCredentials(profileName);
  if (!stored) {
    throw new Error(
      `Profile "${profileName}" has no credentials. Run: auth login`
    );
  }
  const zalo = createZaloClient(options);
  return zalo.login(toCredentials(stored));
}
async function loginWithCredentialPayload(profileName, credentials) {
  const zalo = createZaloClient();
  const api = await zalo.login(credentials);
  await saveCredentials(profileName, {
    imei: credentials.imei,
    cookie: credentials.cookie,
    userAgent: credentials.userAgent,
    language: credentials.language
  });
  return api;
}
async function loginWithQrAndPersist(profileName, qrPath, opts) {
  const zalo = createZaloClient();
  let captured = null;
  const callback = async (event) => {
    switch (event.type) {
      case LoginQRCallbackEventType.QRCodeGenerated: {
        console.log("\nScan this QR in your Zalo app:\n");
        const targetPath = qrPath ?? "qr.png";
        await event.actions.saveToFile(targetPath);
        const absolutePath = path3.resolve(targetPath);
        const rendered = renderInlineQrPngIfSupported(
          event.data.image,
          targetPath
        );
        const asciiRendered = !rendered ? renderAsciiQrFromCode(event.data.code) : false;
        const autoOpenHeadless = !process.stdout.isTTY && process.env.OPENZCA_QR_AUTO_OPEN !== "0";
        const shouldOpen = Boolean(opts?.openQr) || process.env.OPENZCA_QR_OPEN === "1" || autoOpenHeadless;
        if (shouldOpen) {
          const opened = tryOpenFile(absolutePath);
          if (opened) {
            console.log(`Opened QR image in default viewer: ${absolutePath}`);
          } else {
            console.log(`Could not auto-open QR image: ${absolutePath}`);
          }
        }
        if (!rendered && !asciiRendered) {
          console.log("This terminal does not support inline QR rendering.");
        } else if (asciiRendered) {
          console.log("Scan the QR code above with Zalo app to login.");
        }
        console.log(`QR code saved to: ${targetPath}`);
        console.log(`QR file path: ${absolutePath}`);
        console.log(`If QR is not visible, run: ${formatOpenInstruction(absolutePath)}`);
        break;
      }
      case LoginQRCallbackEventType.QRCodeScanned: {
        console.log(`Scanned by: ${event.data.display_name}`);
        break;
      }
      case LoginQRCallbackEventType.QRCodeDeclined: {
        console.log("QR login declined on phone. Retry by running auth login again.");
        break;
      }
      case LoginQRCallbackEventType.QRCodeExpired: {
        console.log("QR expired. Retrying...");
        break;
      }
      case LoginQRCallbackEventType.GotLoginInfo: {
        captured = {
          imei: event.data.imei,
          cookie: event.data.cookie,
          userAgent: event.data.userAgent
        };
        break;
      }
      default: {
        break;
      }
    }
  };
  const api = await zalo.loginQR({ qrPath }, callback);
  if (!captured) {
    const ctx = api.getContext();
    const cookieJar = api.getCookie();
    if (!cookieJar) {
      throw new Error("Cannot extract cookie jar from API context.");
    }
    const cookieJson = cookieJar.toJSON();
    captured = {
      imei: ctx.imei,
      cookie: cookieJson?.cookies ?? [],
      userAgent: ctx.userAgent,
      language: ctx.language
    };
  }
  await saveCredentials(profileName, {
    imei: captured.imei,
    cookie: captured.cookie,
    userAgent: captured.userAgent,
    language: captured.language
  });
  return { api, credentials: captured };
}

// src/lib/media.ts
import fs4 from "fs/promises";
import os2 from "os";
import path4 from "path";
import { fileURLToPath } from "url";
var CONTENT_TYPE_EXT = {
  "image/jpeg": ".jpg",
  "image/jpg": ".jpg",
  "image/png": ".png",
  "image/webp": ".webp",
  "image/gif": ".gif",
  "image/heic": ".heic",
  "image/heif": ".heif",
  "video/mp4": ".mp4",
  "video/quicktime": ".mov",
  "video/webm": ".webm",
  "audio/mpeg": ".mp3",
  "audio/mp3": ".mp3",
  "audio/aac": ".aac",
  "audio/x-aac": ".aac",
  "audio/mp4": ".m4a",
  "audio/x-m4a": ".m4a",
  "audio/wav": ".wav",
  "audio/ogg": ".ogg",
  "audio/webm": ".webm",
  "application/pdf": ".pdf",
  "application/msword": ".doc",
  "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
  "application/vnd.ms-excel": ".xls",
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
  "application/vnd.ms-excel.sheet.binary.macroenabled.12": ".xlsb",
  "application/vnd.ms-excel.sheet.macroenabled.12": ".xlsm",
  "application/vnd.ms-powerpoint": ".ppt",
  "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
  "application/json": ".json",
  "application/zip": ".zip",
  "application/gzip": ".gz",
  "text/plain": ".txt",
  "text/csv": ".csv",
  "text/tab-separated-values": ".tsv",
  "text/markdown": ".md"
};
function collectValues(value, previous) {
  previous.push(value);
  return previous;
}
function expandLeadingTilde(value) {
  if (value === "~") {
    return os2.homedir();
  }
  if (value.startsWith("~/") || value.startsWith("~\\")) {
    return path4.join(os2.homedir(), value.slice(2));
  }
  return value;
}
function normalizeMediaInput(value) {
  const trimmed = value.trim();
  if (!trimmed) return "";
  if (/^file:\/\//i.test(trimmed)) {
    try {
      return expandLeadingTilde(fileURLToPath(trimmed));
    } catch {
      return expandLeadingTilde(trimmed.replace(/^file:\/\//i, ""));
    }
  }
  return expandLeadingTilde(trimmed);
}
function normalizeInputList(values) {
  if (!values || values.length === 0) return [];
  return values.flatMap((value) => value.split(",")).map((value) => normalizeMediaInput(value)).filter(Boolean);
}
function isHttpUrl(value) {
  return /^https?:\/\//i.test(value);
}
function inferExt(url, contentType) {
  if (contentType) {
    const normalized = contentType.split(";")[0].trim().toLowerCase();
    if (CONTENT_TYPE_EXT[normalized]) {
      return CONTENT_TYPE_EXT[normalized];
    }
  }
  try {
    const pathname = new URL(url).pathname;
    const ext = path4.extname(pathname);
    if (ext) return ext;
  } catch {
  }
  return ".bin";
}
async function downloadUrlsToTempFiles(urls) {
  if (urls.length === 0) {
    return {
      files: [],
      cleanup: async () => Promise.resolve()
    };
  }
  const dir = await fs4.mkdtemp(path4.join(os2.tmpdir(), "openzca-"));
  const files = [];
  for (let i = 0; i < urls.length; i += 1) {
    const url = urls[i];
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`Failed to download URL: ${url} (${response.status})`);
    }
    const ext = inferExt(url, response.headers.get("content-type"));
    const filePath = path4.join(dir, `url-${i + 1}${ext}`);
    const data = Buffer.from(await response.arrayBuffer());
    await fs4.writeFile(filePath, data);
    files.push(filePath);
  }
  return {
    files,
    cleanup: async () => {
      await fs4.rm(dir, { recursive: true, force: true });
    }
  };
}
async function assertFilesExist(files) {
  for (const file of files) {
    try {
      await fs4.access(file);
    } catch {
      throw new Error(`File not found: ${file}`);
    }
  }
}

// src/lib/group-poll.ts
function parsePositiveInteger(value, label) {
  const normalized = value.trim();
  if (!/^[1-9]\d*$/.test(normalized)) {
    throw new Error(`${label} must be a positive integer.`);
  }
  const parsed = Number.parseInt(normalized, 10);
  if (!Number.isSafeInteger(parsed) || parsed <= 0) {
    throw new Error(`${label} must be a positive integer.`);
  }
  return parsed;
}
function requireQuestion(value) {
  const normalized = value?.trim() ?? "";
  if (!normalized) {
    throw new Error("Poll question is required.");
  }
  return normalized;
}
function normalizeOptions(values) {
  const normalized = (values ?? []).map((value) => value.trim());
  if (normalized.length < 2) {
    throw new Error("Poll must include at least two options.");
  }
  for (let index = 0; index < normalized.length; index += 1) {
    if (!normalized[index]) {
      throw new Error(`Poll option ${index + 1} cannot be empty.`);
    }
  }
  return normalized;
}
function parsePollId(value) {
  return parsePositiveInteger(value, "Poll id");
}
function parsePollOptionIds(values) {
  const optionIds = values ?? [];
  if (optionIds.length === 0) {
    throw new Error("Provide at least one option id.");
  }
  return optionIds.map((value) => parsePositiveInteger(value, "Option id"));
}
function buildCreatePollOptions(options) {
  const expireMs = options.expireMs?.trim();
  return {
    question: requireQuestion(options.question),
    options: normalizeOptions(options.option),
    expiredTime: expireMs ? parsePositiveInteger(expireMs, "expire-ms") : void 0,
    allowMultiChoices: Boolean(options.multi),
    allowAddNewOption: Boolean(options.allowAddOption),
    hideVotePreview: Boolean(options.hideVotePreview),
    isAnonymous: Boolean(options.anonymous)
  };
}

// src/lib/listen-poll.ts
var POLL_ID_KEYS = /* @__PURE__ */ new Set(["pollId", "poll_id", "pollID", "pollid"]);
var OPTION_ID_KEYS = /* @__PURE__ */ new Set(["optionId", "option_id", "optionID", "optionid"]);
var TITLE_KEYS = ["question", "title"];
function looksLikeStructuredJsonString(value) {
  const trimmed = value.trim();
  if (trimmed.length < 2) return false;
  const first = trimmed[0];
  const last = trimmed[trimmed.length - 1];
  return first === "{" && last === "}" || first === "[" && last === "]";
}
function parseStructuredJsonString(value) {
  if (!looksLikeStructuredJsonString(value)) return void 0;
  try {
    return JSON.parse(value);
  } catch {
    return void 0;
  }
}
function normalizeIdentifier(value) {
  if (typeof value === "number" && Number.isInteger(value) && value > 0) {
    return Number.isSafeInteger(value) ? value : String(value);
  }
  if (typeof value !== "string") return void 0;
  const normalized = value.trim();
  if (!/^[1-9]\d*$/.test(normalized)) return void 0;
  const numeric = Number(normalized);
  return Number.isSafeInteger(numeric) ? numeric : normalized;
}
function firstString(record, keys) {
  for (const key of keys) {
    const value = record[key];
    if (typeof value === "string" && value.trim()) {
      return value.trim();
    }
  }
  return void 0;
}
function asRecord(value) {
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
  return value;
}
function extractInboundPollInfo(...values) {
  const optionIds = [];
  const seenOptionIds = /* @__PURE__ */ new Set();
  let pollId;
  let title;
  const pushOptionId = (value) => {
    const optionId = normalizeIdentifier(value);
    if (!optionId) return;
    const key = String(optionId);
    if (seenOptionIds.has(key)) return;
    seenOptionIds.add(key);
    optionIds.push(optionId);
  };
  const visit = (value, depth = 0) => {
    if (depth > 8 || value === null || value === void 0) return;
    if (typeof value === "string") {
      const parsed = parseStructuredJsonString(value);
      if (parsed !== void 0) visit(parsed, depth + 1);
      return;
    }
    if (Array.isArray(value)) {
      for (const entry of value) visit(entry, depth + 1);
      return;
    }
    const record = asRecord(value);
    if (!record) return;
    for (const [key, nested] of Object.entries(record)) {
      if (!pollId && POLL_ID_KEYS.has(key)) {
        pollId = normalizeIdentifier(nested);
      }
      if (OPTION_ID_KEYS.has(key)) {
        pushOptionId(nested);
      }
    }
    title ??= firstString(record, TITLE_KEYS);
    for (const nested of Object.values(record)) {
      visit(nested, depth + 1);
    }
  };
  for (const value of values) visit(value);
  if (!pollId) return null;
  return {
    pollId,
    ...title ? { title } : {},
    ...optionIds.length > 0 ? { optionIds } : {}
  };
}

// src/lib/time-range.ts
var DURATION_PART_RE = /(\d+)\s*(ms|s|m|h|d|w)/gi;
function durationToMs(input) {
  const text = input.trim().toLowerCase();
  if (!text) {
    return null;
  }
  let total = 0;
  let matched = 0;
  for (const match of text.matchAll(DURATION_PART_RE)) {
    const rawAmount = match[1];
    const unit = match[2];
    const amount = Number(rawAmount);
    if (!Number.isFinite(amount) || amount < 0) {
      return null;
    }
    matched += match[0].length;
    switch (unit) {
      case "ms":
        total += amount;
        break;
      case "s":
        total += amount * 1e3;
        break;
      case "m":
        total += amount * 60 * 1e3;
        break;
      case "h":
        total += amount * 60 * 60 * 1e3;
        break;
      case "d":
        total += amount * 24 * 60 * 60 * 1e3;
        break;
      case "w":
        total += amount * 7 * 24 * 60 * 60 * 1e3;
        break;
      default:
        return null;
    }
  }
  if (matched === 0) {
    return null;
  }
  const normalized = text.replace(/\s+/g, "");
  const consumed = Array.from(normalized.matchAll(DURATION_PART_RE)).map((match) => match[0]).join("");
  if (consumed !== normalized) {
    return null;
  }
  return total;
}
function parseDurationInput(value, nowMs = Date.now()) {
  if (!value || !value.trim()) {
    return void 0;
  }
  const durationMs = durationToMs(value.trim());
  if (durationMs == null) {
    return void 0;
  }
  return nowMs - durationMs;
}
function parseTimeBoundaryInput(value, _nowMs = Date.now()) {
  if (!value || !value.trim()) {
    return void 0;
  }
  const trimmed = value.trim();
  const numeric = Number(trimmed);
  if (Number.isFinite(numeric) && numeric > 0) {
    return numeric > 1e10 ? Math.trunc(numeric) : Math.trunc(numeric * 1e3);
  }
  const parsed = Date.parse(trimmed);
  if (Number.isFinite(parsed)) {
    return parsed;
  }
  return void 0;
}

// src/lib/group-mentions.ts
var ALLOWED_START_BOUNDARY_CHARS = /* @__PURE__ */ new Set(["(", "[", "{", "<", '"', ",", ";", ":"]);
var ALLOWED_END_BOUNDARY_CHARS = /* @__PURE__ */ new Set([",", ";", ":", "!", "?", ")", "]", "}", ">", '"']);
function resolveOutboundGroupMentions(text, members) {
  if (!text.includes("@") || members.length === 0) {
    return [];
  }
  const candidates = buildCandidates(members);
  if (candidates.length === 0) {
    return [];
  }
  const lowerText = text.toLowerCase();
  const mentions = [];
  for (let index = 0; index < text.length; index += 1) {
    if (text[index] !== "@") continue;
    if (!isMentionStartBoundary(text, index)) continue;
    const match = resolveMentionAtIndex(text, lowerText, index, candidates);
    if (!match) continue;
    mentions.push({
      pos: index,
      len: match.label.length + 1,
      uid: match.userId
    });
    index += match.label.length;
  }
  return mentions;
}
function hasPotentialOutboundGroupMention(text) {
  for (let index = 0; index < text.length; index += 1) {
    if (text[index] !== "@") continue;
    if (!isMentionStartBoundary(text, index)) continue;
    const next = text[index + 1];
    if (next && !/\s/u.test(next)) {
      return true;
    }
  }
  return false;
}
function resolveMentionAtIndex(text, lowerText, atIndex, candidates) {
  const start = atIndex + 1;
  const matches = candidates.filter((candidate) => {
    const end = start + candidate.label.length;
    return lowerText.startsWith(candidate.normalizedLabel, start) && isMentionBoundary(text, start, end);
  });
  if (matches.length === 0) {
    return null;
  }
  const longestLength = matches.reduce((max, candidate) => Math.max(max, candidate.label.length), 0);
  const longestMatches = matches.filter((candidate) => candidate.label.length === longestLength);
  const matchedUserIds = [...new Set(longestMatches.map((candidate) => candidate.userId))];
  if (matchedUserIds.length !== 1) {
    const label = text.slice(atIndex, start + longestLength);
    throw new Error(`Ambiguous mention "${label}": matched multiple group members.`);
  }
  return longestMatches[0];
}
function buildCandidates(members) {
  const candidates = /* @__PURE__ */ new Map();
  for (const member of members) {
    const userId = member.userId.trim();
    if (!userId) continue;
    for (const rawLabel of [member.userId, member.displayName, member.zaloName]) {
      const label = rawLabel?.trim();
      if (!label) continue;
      const normalizedLabel = label.toLowerCase();
      const key = `${userId}\0${normalizedLabel}`;
      if (candidates.has(key)) continue;
      candidates.set(key, {
        label,
        normalizedLabel,
        userId
      });
    }
  }
  return [...candidates.values()].sort((left, right) => right.label.length - left.label.length);
}
function isMentionBoundary(text, start, end) {
  if (end > text.length) {
    return false;
  }
  const next = text[end];
  if (!next) {
    return true;
  }
  if (/\s/u.test(next)) {
    return true;
  }
  if (ALLOWED_END_BOUNDARY_CHARS.has(next)) {
    return true;
  }
  if (next === ".") {
    const following = text[end + 1];
    return !following || /\s/u.test(following) || ALLOWED_END_BOUNDARY_CHARS.has(following);
  }
  return false;
}
function isMentionStartBoundary(text, atIndex) {
  if (atIndex === 0) {
    return true;
  }
  const previous = text[atIndex - 1];
  return /\s/u.test(previous) || ALLOWED_START_BOUNDARY_CHARS.has(previous);
}

// src/lib/text-send.ts
import { ThreadType } from "zca-js";

// src/lib/text-styles.ts
import { TextStyle } from "zca-js";
var TAG_STYLE_MAP = {
  red: TextStyle.Red,
  orange: TextStyle.Orange,
  yellow: TextStyle.Yellow,
  green: TextStyle.Green,
  small: null,
  big: TextStyle.Big,
  underline: TextStyle.Underline
};
var INLINE_MARKERS = [
  {
    pattern: new RegExp(`\\{(${Object.keys(TAG_STYLE_MAP).join("|")})\\}(.+?)\\{/\\1\\}`, "g"),
    style: null
  },
  { pattern: /\*\*\*(.+?)\*\*\*/g, style: TextStyle.Bold, extraStyles: [TextStyle.Italic] },
  { pattern: /\*\*(.+?)\*\*/g, style: TextStyle.Bold },
  { pattern: /(?<!\w)__(.+?)__(?!\w)/g, style: TextStyle.Bold },
  { pattern: /\*(.+?)\*/g, style: TextStyle.Italic },
  { pattern: /(?<!\w)_(.+?)_(?!\w)/g, style: TextStyle.Italic },
  { pattern: /~~(.+?)~~/g, style: TextStyle.StrikeThrough }
];
function parseTextStyles(input) {
  const allStyles = [];
  const escapeMap = [];
  const escapedInput = input.replace(/\\([*_~#\\{}>+\-])/g, (_match, ch) => {
    const index = escapeMap.length;
    escapeMap.push(ch);
    return `${index}`;
  });
  const lines = escapedInput.split("\n");
  const lineStyles = [];
  const processedLines = [];
  const codeOutputLineIndices = /* @__PURE__ */ new Set();
  let inCodeBlock = false;
  for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
    let line = lines[lineIndex];
    let baseIndent = 0;
    if (/^```/.test(line)) {
      inCodeBlock = !inCodeBlock;
      continue;
    }
    if (inCodeBlock) {
      const outputLineIndex = processedLines.length;
      codeOutputLineIndices.add(outputLineIndex);
      processedLines.push(normalizeCodeBlockLeadingWhitespace(line));
      continue;
    }
    const headingMatch = line.match(/^(#{1,4})\s(.*)$/);
    if (headingMatch) {
      const depth = headingMatch[1].length;
      lineStyles.push({ lineIndex, style: TextStyle.Bold });
      if (depth === 1) {
        lineStyles.push({ lineIndex, style: TextStyle.Big });
      }
      processedLines.push(headingMatch[2]);
      continue;
    }
    const quoteMatch = line.match(/^(>+)\s?(.*)$/);
    if (quoteMatch) {
      baseIndent = Math.min(5, quoteMatch[1].length);
      line = quoteMatch[2];
    }
    const indentMatch = line.match(/^(\s+)(.*)$/);
    let indentLevel = 0;
    let content = line;
    if (indentMatch) {
      indentLevel = clampIndent(indentMatch[1].length);
      content = indentMatch[2];
    }
    const totalIndent = Math.min(5, baseIndent + indentLevel);
    if (/^[-*+]\s\[[ xX]\]\s/.test(content)) {
      if (totalIndent > 0) {
        lineStyles.push({ lineIndex, style: TextStyle.Indent, indentSize: totalIndent });
      }
      processedLines.push(content);
      continue;
    }
    const orderedListMatch = content.match(/^(\d+)\.\s(.*)$/);
    if (orderedListMatch) {
      if (totalIndent > 0) {
        lineStyles.push({ lineIndex, style: TextStyle.Indent, indentSize: totalIndent });
      }
      lineStyles.push({ lineIndex, style: TextStyle.OrderedList });
      processedLines.push(orderedListMatch[2]);
      continue;
    }
    const unorderedListMatch = content.match(/^[-*+]\s(.*)$/);
    if (unorderedListMatch) {
      if (totalIndent > 0) {
        lineStyles.push({ lineIndex, style: TextStyle.Indent, indentSize: totalIndent });
      }
      lineStyles.push({ lineIndex, style: TextStyle.UnorderedList });
      processedLines.push(unorderedListMatch[1]);
      continue;
    }
    if (totalIndent > 0) {
      lineStyles.push({ lineIndex, style: TextStyle.Indent, indentSize: totalIndent });
      processedLines.push(content);
      continue;
    }
    processedLines.push(line);
  }
  for (const codeLineIndex of codeOutputLineIndices) {
    if (codeLineIndex >= processedLines.length) {
      continue;
    }
    processedLines[codeLineIndex] = processedLines[codeLineIndex].replace(/[*_~{}]/g, (ch) => {
      const index = escapeMap.length;
      escapeMap.push(ch);
      return `${index}`;
    });
  }
  let segments = [{ text: processedLines.join("\n"), styles: [] }];
  for (const marker of INLINE_MARKERS) {
    const nextSegments = [];
    for (const segment of segments) {
      let lastIndex = 0;
      const regex = new RegExp(marker.pattern.source, marker.pattern.flags);
      let match;
      while ((match = regex.exec(segment.text)) !== null) {
        if (match.index > lastIndex) {
          nextSegments.push({
            text: segment.text.slice(lastIndex, match.index),
            styles: [...segment.styles]
          });
        }
        const isTagPattern = marker.style === null;
        const innerText = isTagPattern ? match[2] : match[1];
        const resolvedStyle = isTagPattern ? TAG_STYLE_MAP[match[1]] : marker.style;
        const combinedStyles = [...segment.styles];
        if (resolvedStyle) {
          combinedStyles.push(resolvedStyle);
        }
        if (marker.extraStyles) {
          combinedStyles.push(...marker.extraStyles);
        }
        nextSegments.push({
          text: innerText,
          styles: combinedStyles
        });
        lastIndex = regex.lastIndex;
      }
      if (lastIndex < segment.text.length) {
        nextSegments.push({
          text: segment.text.slice(lastIndex),
          styles: [...segment.styles]
        });
      } else if (lastIndex === 0) {
        nextSegments.push(segment);
      }
    }
    segments = nextSegments;
  }
  let plainText = "";
  for (const segment of segments) {
    const start = plainText.length;
    plainText += segment.text;
    for (const style of segment.styles) {
      allStyles.push({ start, len: segment.text.length, st: style });
    }
  }
  const orphanMatches = [...plainText.matchAll(/\*([^*\n]+?)\*/g)];
  for (let index = orphanMatches.length - 1; index >= 0; index -= 1) {
    const match = orphanMatches[index];
    const openPos = match.index ?? 0;
    const content = match[1];
    const closePos = openPos + content.length + 1;
    allStyles.push({ start: openPos + 1, len: content.length, st: TextStyle.Italic });
    plainText = plainText.slice(0, closePos) + plainText.slice(closePos + 1);
    plainText = plainText.slice(0, openPos) + plainText.slice(openPos + 1);
    for (const style of allStyles) {
      if (style.start > closePos) {
        style.start -= 1;
      } else if (style.start + style.len > closePos) {
        style.len -= 1;
      }
      if (style.start > openPos) {
        style.start -= 1;
      } else if (style.start + style.len > openPos) {
        style.len -= 1;
      }
    }
  }
  if (escapeMap.length > 0) {
    const escapeRegex = /\x01(\d+)\x02/g;
    const shifts = [];
    let cumulativeDelta = 0;
    for (const match of plainText.matchAll(escapeRegex)) {
      const escapeIndex = Number.parseInt(match[1], 10);
      cumulativeDelta += match[0].length - escapeMap[escapeIndex].length;
      shifts.push({ pos: (match.index ?? 0) + match[0].length, delta: cumulativeDelta });
    }
    for (const style of allStyles) {
      let startDelta = 0;
      let endDelta = 0;
      const end = style.start + style.len;
      for (const shift of shifts) {
        if (shift.pos <= style.start) {
          startDelta = shift.delta;
        }
        if (shift.pos <= end) {
          endDelta = shift.delta;
        }
      }
      style.start -= startDelta;
      style.len -= endDelta - startDelta;
    }
    plainText = plainText.replace(escapeRegex, (_match, index) => escapeMap[Number.parseInt(index, 10)]);
  }
  const finalLines = plainText.split("\n");
  let offset = 0;
  for (let lineIndex = 0; lineIndex < finalLines.length; lineIndex += 1) {
    const lineLength = finalLines[lineIndex].length;
    if (lineLength > 0) {
      for (const lineStyle of lineStyles) {
        if (lineStyle.lineIndex !== lineIndex) {
          continue;
        }
        if (lineStyle.style === TextStyle.Indent) {
          allStyles.push({
            start: offset,
            len: lineLength,
            st: TextStyle.Indent,
            indentSize: lineStyle.indentSize
          });
        } else {
          allStyles.push({ start: offset, len: lineLength, st: lineStyle.style });
        }
      }
    }
    offset += lineLength + 1;
  }
  return { text: plainText, styles: allStyles };
}
function clampIndent(spaceCount) {
  return Math.min(5, Math.max(1, Math.floor(spaceCount / 2)));
}
function normalizeCodeBlockLeadingWhitespace(line) {
  return line.replace(
    /^[ \t]+/,
    (leadingWhitespace) => leadingWhitespace.replace(/\t/g, "\xA0\xA0\xA0\xA0").replace(/ /g, "\xA0")
  );
}

// src/lib/text-send.ts
var ZALO_TEXT_MESSAGE_MAX_LENGTH = 2e3;
var ZALO_TEXT_REQUEST_PARAMS_MAX_ESTIMATE = 4e3;
async function buildTextSendPayload(params) {
  if (params.raw) {
    const mentions2 = await resolveGroupMentionsIfNeeded(params, params.message);
    return mentions2 ? { msg: params.message, mentions: mentions2 } : params.message;
  }
  const { text, styles } = parseTextStyles(params.message);
  const mentions = await resolveGroupMentionsIfNeeded(params, text);
  return {
    msg: text,
    styles: styles.length > 0 ? styles : void 0,
    mentions
  };
}
async function analyzeTextSendPayload(params) {
  const payload = await buildTextSendPayload(params);
  const payloadObject = normalizeTextSendPayload(payload);
  const textProperties = buildTextProperties(payloadObject.styles);
  const mentionInfo = buildMentionInfo(
    params.threadType,
    payloadObject.msg,
    payloadObject.mentions
  );
  const requestParams = omitUndefined({
    message: payloadObject.msg,
    clientId: 17e11,
    mentionInfo,
    imei: params.threadType === ThreadType.Group ? void 0 : "000000000000000",
    ttl: 0,
    visibility: params.threadType === ThreadType.Group ? 0 : void 0,
    toid: params.threadType === ThreadType.Group ? void 0 : params.threadId,
    grid: params.threadType === ThreadType.Group ? params.threadId : void 0,
    textProperties
  });
  return {
    payload,
    ...buildTextSendPayloadAnalysis({
      payloadObject,
      rawInputLength: params.message.length,
      textProperties,
      mentionInfo,
      requestParamsLengthEstimate: JSON.stringify(requestParams).length,
      threadType: params.threadType
    })
  };
}
function planTextSendPayloadsForDelivery(params) {
  const maxMessageLength = resolvePositiveLimit(
    params.maxMessageLength,
    ZALO_TEXT_MESSAGE_MAX_LENGTH
  );
  const maxRequestParamsLengthEstimate = resolvePositiveLimit(
    params.maxRequestParamsLengthEstimate,
    ZALO_TEXT_REQUEST_PARAMS_MAX_ESTIMATE
  );
  const chunks = [];
  const analyses = [];
  const pending = [params.payload];
  while (pending.length > 0) {
    const currentPayload = pending.shift();
    const analysis = analyzePreparedTextSendPayload({
      payload: currentPayload,
      threadType: params.threadType,
      threadId: params.threadId
    });
    if (isTextSendPayloadWithinDeliveryLimits(analysis, {
      maxMessageLength,
      maxRequestParamsLengthEstimate
    })) {
      chunks.push(currentPayload);
      analyses.push(analysis);
      continue;
    }
    const targetLength = computeNextChunkLength(analysis, {
      maxMessageLength,
      maxRequestParamsLengthEstimate
    });
    const splitChunks = splitTextSendPayload(currentPayload, targetLength);
    if (splitChunks.length <= 1) {
      throw new Error(
        `Unable to split formatted text payload into deliverable chunks within ${targetLength} characters.`
      );
    }
    pending.unshift(...splitChunks);
  }
  return { chunks, analyses };
}
function splitTextSendPayload(payload, maxLength = ZALO_TEXT_MESSAGE_MAX_LENGTH) {
  if (!Number.isInteger(maxLength) || maxLength <= 0) {
    throw new Error("Text chunk size must be a positive integer");
  }
  const payloadObject = normalizeTextSendPayload(payload);
  if (payloadObject.msg.length <= maxLength) {
    return [payload];
  }
  const chunks = [];
  let start = 0;
  while (start < payloadObject.msg.length) {
    const end = findChunkEnd(payloadObject, start, maxLength);
    chunks.push(sliceTextSendPayload(payloadObject, start, end));
    start = end;
  }
  return chunks;
}
function analyzePreparedTextSendPayload(params) {
  const payloadObject = normalizeTextSendPayload(params.payload);
  const textProperties = buildTextProperties(payloadObject.styles);
  const mentionInfo = buildMentionInfo(
    params.threadType,
    payloadObject.msg,
    payloadObject.mentions
  );
  const requestParams = omitUndefined({
    message: payloadObject.msg,
    clientId: 17e11,
    mentionInfo,
    imei: params.threadType === ThreadType.Group ? void 0 : "000000000000000",
    ttl: 0,
    visibility: params.threadType === ThreadType.Group ? 0 : void 0,
    toid: params.threadType === ThreadType.Group ? void 0 : params.threadId,
    grid: params.threadType === ThreadType.Group ? params.threadId : void 0,
    textProperties
  });
  return {
    payload: params.payload,
    ...buildTextSendPayloadAnalysis({
      payloadObject,
      rawInputLength: payloadObject.msg.length,
      textProperties,
      mentionInfo,
      requestParamsLengthEstimate: JSON.stringify(requestParams).length,
      threadType: params.threadType
    })
  };
}
async function resolveGroupMentionsIfNeeded(params, text) {
  if (params.threadType !== ThreadType.Group) {
    return void 0;
  }
  if (!hasPotentialOutboundGroupMention(text)) {
    return void 0;
  }
  if (!params.listGroupMembers) {
    return void 0;
  }
  const members = await params.listGroupMembers(params.threadId);
  const mentions = resolveOutboundGroupMentions(text, members);
  return mentions.length > 0 ? mentions : void 0;
}
function normalizeTextSendPayload(payload) {
  if (typeof payload === "string") {
    return { msg: payload };
  }
  return payload;
}
function buildTextSendPayloadAnalysis(params) {
  return {
    payloadObject: params.payloadObject,
    rawInputLength: params.rawInputLength,
    renderedTextLength: params.payloadObject.msg.length,
    styleCount: params.payloadObject.styles?.length ?? 0,
    mentionCount: params.payloadObject.mentions?.length ?? 0,
    textPropertiesLength: params.textProperties?.length ?? 0,
    mentionInfoLength: params.mentionInfo?.length ?? 0,
    requestParamsLengthEstimate: params.requestParamsLengthEstimate,
    sendPath: params.threadType === ThreadType.Group ? params.mentionInfo ? "mention" : "sendmsg" : "sms"
  };
}
function isTextSendPayloadWithinDeliveryLimits(analysis, limits) {
  return analysis.renderedTextLength <= limits.maxMessageLength && analysis.requestParamsLengthEstimate <= limits.maxRequestParamsLengthEstimate;
}
function computeNextChunkLength(analysis, limits) {
  const currentLength = analysis.renderedTextLength;
  const targetLengths = [limits.maxMessageLength, currentLength - 1].filter((value) => value > 0);
  if (analysis.requestParamsLengthEstimate > limits.maxRequestParamsLengthEstimate) {
    targetLengths.push(
      Math.floor(
        currentLength * limits.maxRequestParamsLengthEstimate / analysis.requestParamsLengthEstimate
      )
    );
  }
  const targetLength = Math.max(
    1,
    Math.min(...targetLengths.filter((value) => Number.isFinite(value) && value > 0))
  );
  return Math.min(targetLength, currentLength - 1);
}
function resolvePositiveLimit(value, fallback) {
  if (!value || !Number.isInteger(value) || value <= 0) {
    return fallback;
  }
  return value;
}
function sliceTextSendPayload(payloadObject, start, end) {
  const msg2 = payloadObject.msg.slice(start, end);
  const styles = sliceStyles(payloadObject.styles, start, end);
  const mentions = sliceMentions(payloadObject.mentions, start, end);
  if (!styles && !mentions) {
    return msg2;
  }
  return omitUndefined({
    msg: msg2,
    styles,
    mentions
  });
}
function sliceStyles(styles, start, end) {
  if (!styles || styles.length === 0) {
    return void 0;
  }
  const sliced = [];
  for (const style of styles) {
    const styleStart = style.start;
    const styleEnd = style.start + style.len;
    const overlapStart = Math.max(styleStart, start);
    const overlapEnd = Math.min(styleEnd, end);
    if (overlapStart >= overlapEnd) {
      continue;
    }
    if (style.st === "ind_$") {
      sliced.push({
        start: overlapStart - start,
        len: overlapEnd - overlapStart,
        st: style.st,
        indentSize: style.indentSize
      });
      continue;
    }
    sliced.push({
      start: overlapStart - start,
      len: overlapEnd - overlapStart,
      st: style.st
    });
  }
  return sliced.length > 0 ? sliced : void 0;
}
function sliceMentions(mentions, start, end) {
  if (!mentions || mentions.length === 0) {
    return void 0;
  }
  const sliced = mentions.filter((mention) => mention.pos >= start && mention.pos + mention.len <= end).map((mention) => ({
    pos: mention.pos - start,
    uid: mention.uid,
    len: mention.len
  }));
  return sliced.length > 0 ? sliced : void 0;
}
function findChunkEnd(payloadObject, start, maxLength) {
  const remaining = payloadObject.msg.length - start;
  if (remaining <= maxLength) {
    return payloadObject.msg.length;
  }
  const maxEnd = start + maxLength;
  const newlineBreak = findPreferredBreak(payloadObject, start, maxEnd, "\n");
  if (newlineBreak > start) {
    return newlineBreak;
  }
  const whitespaceBreak = findWhitespaceBreak(payloadObject, start, maxEnd);
  if (whitespaceBreak > start) {
    return whitespaceBreak;
  }
  for (let cursor = maxEnd; cursor > start; cursor -= 1) {
    if (isSafeSplitPosition(payloadObject.mentions, cursor)) {
      return cursor;
    }
  }
  throw new Error(
    `Unable to split text payload safely within ${maxLength} characters.`
  );
}
function findPreferredBreak(payloadObject, start, maxEnd, marker) {
  for (let cursor = maxEnd; cursor > start; cursor -= 1) {
    if (!isSafeSplitPosition(payloadObject.mentions, cursor)) {
      continue;
    }
    if (payloadObject.msg[cursor - 1] === marker) {
      return cursor;
    }
  }
  return start;
}
function findWhitespaceBreak(payloadObject, start, maxEnd) {
  for (let cursor = maxEnd; cursor > start; cursor -= 1) {
    if (!isSafeSplitPosition(payloadObject.mentions, cursor)) {
      continue;
    }
    const previousChar = payloadObject.msg[cursor - 1];
    if (previousChar === " " || previousChar === "	") {
      return cursor;
    }
  }
  return start;
}
function isSafeSplitPosition(mentions, position) {
  if (!mentions || mentions.length === 0) {
    return true;
  }
  return mentions.every((mention) => {
    const mentionEnd = mention.pos + mention.len;
    return position <= mention.pos || position >= mentionEnd;
  });
}
function buildTextProperties(styles) {
  if (!styles || styles.length === 0) {
    return void 0;
  }
  return JSON.stringify({
    styles: styles.map((style) => {
      if (style.st === "ind_$") {
        return omitUndefined({
          start: style.start,
          len: style.len,
          st: `ind_${style.indentSize ?? 1}0`
        });
      }
      return {
        start: style.start,
        len: style.len,
        st: style.st
      };
    }),
    ver: 0
  });
}
function buildMentionInfo(threadType, msg2, mentions) {
  if (threadType !== ThreadType.Group || !mentions || mentions.length === 0) {
    return void 0;
  }
  let totalMentionLen = 0;
  const mentionsFinal = mentions.filter((mention) => mention.pos >= 0 && Boolean(mention.uid) && mention.len > 0).map((mention) => {
    totalMentionLen += mention.len;
    return {
      pos: mention.pos,
      uid: mention.uid,
      len: mention.len,
      type: mention.uid === "-1" ? 1 : 0
    };
  });
  if (totalMentionLen > msg2.length) {
    throw new Error("Invalid mentions: total mention characters exceed message length");
  }
  if (mentionsFinal.length === 0) {
    return void 0;
  }
  return JSON.stringify(mentionsFinal);
}
function omitUndefined(value) {
  return Object.fromEntries(
    Object.entries(value).filter(([, entry]) => entry !== void 0)
  );
}

// src/lib/video-send.ts
import { execFile } from "child_process";
import fs5 from "fs/promises";
import os3 from "os";
import path5 from "path";
import { promisify } from "util";

// src/lib/send-retry.ts
var DEFAULT_SEND_RETRY_COUNT = 1;
var DEFAULT_SEND_RETRY_DELAY_MS = 750;
var RETRYABLE_SEND_ERROR_PATTERNS = [
  /retry limit/i,
  /\btimeout\b/i,
  /\btimed out\b/i,
  /\betimedout\b/i,
  /\beconnreset\b/i,
  /\besockettimedout\b/i,
  /\bsocket hang up\b/i,
  /\btemporar(?:y|ily)\b/i
];
function sleep(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}
function parsePositiveIntEnv(value, fallback) {
  const raw = value?.trim();
  if (!raw) return fallback;
  if (raw === "0") return 0;
  const parsed = Number.parseInt(raw, 10);
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function getSendRetryConfigFromEnv(env = process.env) {
  return {
    number: parsePositiveIntEnv(env.OPENZCA_SEND_RETRY_COUNT, DEFAULT_SEND_RETRY_COUNT),
    delayMs: parsePositiveIntEnv(env.OPENZCA_SEND_RETRY_DELAY_MS, DEFAULT_SEND_RETRY_DELAY_MS)
  };
}
function isRetryableSendError(error) {
  const message = error instanceof Error ? error.message : String(error ?? "");
  return RETRYABLE_SEND_ERROR_PATTERNS.some((pattern) => pattern.test(message));
}
function retryable(operation, options) {
  const maxRetries = Math.max(0, options?.number ?? DEFAULT_SEND_RETRY_COUNT);
  const delayMs = Math.max(0, options?.delayMs ?? DEFAULT_SEND_RETRY_DELAY_MS);
  const shouldRetry = options?.on ?? isRetryableSendError;
  return async (...args) => {
    let attempt = 0;
    while (true) {
      try {
        return await operation(...args);
      } catch (error) {
        attempt += 1;
        if (attempt > maxRetries || !shouldRetry(error)) {
          throw error;
        }
        await options?.onRetry?.({
          attempt,
          maxRetries,
          delayMs,
          error,
          args
        });
        if (delayMs > 0) {
          await sleep(delayMs);
        }
      }
    }
  };
}

// src/lib/video-send.ts
var execFileAsync = promisify(execFile);
function planVideoSendMode(params) {
  const { files, ffmpegAvailable } = params;
  if (!ffmpegAvailable) {
    return {
      mode: "attachment",
      reason: "ffmpeg is unavailable for native video mode"
    };
  }
  if (files.length !== 1) {
    return {
      mode: "attachment",
      reason: "native-video mode supports one video at a time"
    };
  }
  const ext = path5.extname(files[0] ?? "").toLowerCase();
  if (ext !== ".mp4") {
    return {
      mode: "attachment",
      reason: "native-video mode currently supports .mp4 inputs only"
    };
  }
  return { mode: "native" };
}
function parseVideoProbeOutput(raw) {
  const parsed = JSON.parse(raw);
  const videoStream = parsed.streams?.find((stream) => stream.codec_type === "video");
  const width = toPositiveInteger(videoStream?.width);
  const height = toPositiveInteger(videoStream?.height);
  const durationSeconds = toPositiveNumber(videoStream?.duration) ?? toPositiveNumber(parsed.format?.duration);
  return {
    durationMs: durationSeconds ? Math.round(durationSeconds * 1e3) : void 0,
    width,
    height
  };
}
function toPositiveInteger(value) {
  if (typeof value === "number" && Number.isFinite(value) && value > 0) {
    return Math.trunc(value);
  }
  if (typeof value === "string") {
    const numeric = Number(value);
    if (Number.isFinite(numeric) && numeric > 0) {
      return Math.trunc(numeric);
    }
  }
  return void 0;
}
function toPositiveNumber(value) {
  if (typeof value === "number" && Number.isFinite(value) && value > 0) {
    return value;
  }
  if (typeof value === "string") {
    const numeric = Number(value);
    if (Number.isFinite(numeric) && numeric > 0) {
      return numeric;
    }
  }
  return void 0;
}
async function runBinary(command, args) {
  try {
    const { stdout } = await execFileAsync(command, args, {
      encoding: "utf8",
      maxBuffer: 10 * 1024 * 1024
    });
    return stdout;
  } catch (error) {
    if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
      throw new Error(`${command} is required for native video mode`);
    }
    if (error instanceof Error && "stderr" in error) {
      const stderr = typeof error.stderr === "string" ? error.stderr.trim() : "";
      throw new Error(stderr ? `${command} failed: ${stderr}` : `${command} failed: ${error.message}`);
    }
    throw error;
  }
}
async function isFfmpegAvailable() {
  try {
    await execFileAsync("ffmpeg", ["-version"], {
      encoding: "utf8",
      maxBuffer: 1024 * 1024
    });
    return true;
  } catch (error) {
    if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
      return false;
    }
    return false;
  }
}
async function maybeProbeVideoFile(filePath) {
  try {
    const raw = await runBinary("ffprobe", [
      "-v",
      "error",
      "-print_format",
      "json",
      "-show_format",
      "-show_streams",
      filePath
    ]);
    return parseVideoProbeOutput(raw);
  } catch {
    return {};
  }
}
async function generateVideoThumbnail(videoPath) {
  const dir = await fs5.mkdtemp(path5.join(os3.tmpdir(), "openzca-video-thumb-"));
  const outputPath = path5.join(dir, "thumbnail.jpg");
  try {
    await runBinary("ffmpeg", [
      "-y",
      "-ss",
      "1",
      "-i",
      videoPath,
      "-frames:v",
      "1",
      "-q:v",
      "2",
      outputPath
    ]);
    await fs5.access(outputPath);
  } catch (error) {
    await fs5.rm(dir, { recursive: true, force: true });
    throw error;
  }
  return {
    path: outputPath,
    cleanup: async () => {
      await fs5.rm(dir, { recursive: true, force: true });
    }
  };
}
function pickUploadedVideoUrl(uploaded) {
  if (!uploaded || !("fileUrl" in uploaded) || typeof uploaded.fileUrl !== "string" || uploaded.fileUrl.length === 0) {
    throw new Error("Video upload did not return a file URL");
  }
  return uploaded.fileUrl;
}
function pickUploadedThumbnailUrl(uploaded) {
  if (!uploaded || uploaded.fileType !== "image") {
    throw new Error("Thumbnail upload did not return an image result");
  }
  return uploaded.normalUrl || uploaded.hdUrl || uploaded.thumbUrl;
}
async function sendNativeVideo(params) {
  const metadata = await maybeProbeVideoFile(params.videoPath);
  const generatedThumbnail = params.thumbnailPath ? null : await generateVideoThumbnail(params.videoPath);
  const thumbnailPath = params.thumbnailPath ?? generatedThumbnail?.path;
  if (!thumbnailPath) {
    throw new Error("Unable to resolve thumbnail path for native video send");
  }
  try {
    const uploadedVideo = await params.api.uploadAttachment([params.videoPath], params.threadId, params.threadType);
    const uploadedThumbnail = await params.api.uploadAttachment([thumbnailPath], params.threadId, params.threadType);
    const sendVideo = retryable(params.api.sendVideo.bind(params.api), getSendRetryConfigFromEnv());
    return await sendVideo(
      {
        msg: params.message ?? "",
        videoUrl: pickUploadedVideoUrl(uploadedVideo[0]),
        thumbnailUrl: pickUploadedThumbnailUrl(uploadedThumbnail[0]),
        duration: metadata.durationMs,
        width: metadata.width,
        height: metadata.height
      },
      params.threadId,
      params.threadType
    );
  } finally {
    await generatedThumbnail?.cleanup();
  }
}

// src/lib/voice-send.ts
import { execFile as execFile2 } from "child_process";
import fs6 from "fs/promises";
import os4 from "os";
import path6 from "path";
import { promisify as promisify2 } from "util";
var execFileAsync2 = promisify2(execFile2);
function sanitizeOutputBasename(filePath) {
  const parsed = path6.parse(filePath);
  const base = parsed.name.trim() || "voice";
  const sanitized = base.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
  return sanitized || "voice";
}
async function runBinary2(command, args) {
  try {
    await execFileAsync2(command, args, {
      encoding: "utf8",
      maxBuffer: 10 * 1024 * 1024
    });
  } catch (error) {
    if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
      throw new Error(`${command} is required for voice publish mode`);
    }
    if (error instanceof Error && "stderr" in error) {
      const stderr = typeof error.stderr === "string" ? error.stderr.trim() : "";
      throw new Error(stderr ? `${command} failed: ${stderr}` : `${command} failed: ${error.message}`);
    }
    throw error;
  }
}
function getVoicePublishCommandFromEnv(env = process.env) {
  const configured = env.OPENZCA_VOICE_PUBLISH_CMD?.trim();
  return configured && configured.length > 0 ? configured : null;
}
function extractPublishedVoiceUrl(stdout) {
  const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
  const candidate = lines.at(-1);
  if (!candidate) {
    throw new Error("Voice publish command did not print a public URL to stdout");
  }
  if (!/^https?:\/\/\S+$/i.test(candidate)) {
    throw new Error(`Voice publish command returned an invalid URL: ${candidate}`);
  }
  return candidate;
}
async function normalizeVoiceForPublish(inputPath) {
  const dir = await fs6.mkdtemp(path6.join(os4.tmpdir(), "openzca-voice-"));
  const outputPath = path6.join(dir, `${sanitizeOutputBasename(inputPath)}.m4a`);
  try {
    await runBinary2("ffmpeg", [
      "-y",
      "-v",
      "error",
      "-i",
      inputPath,
      "-vn",
      "-map_metadata",
      "-1",
      "-ac",
      "1",
      "-ar",
      "44100",
      "-c:a",
      "aac",
      "-b:a",
      "64k",
      "-movflags",
      "+faststart",
      outputPath
    ]);
    await fs6.access(outputPath);
  } catch (error) {
    await fs6.rm(dir, { recursive: true, force: true });
    throw error;
  }
  return {
    path: outputPath,
    cleanup: async () => {
      await fs6.rm(dir, { recursive: true, force: true });
    }
  };
}
async function publishVoiceFile(command, filePath) {
  try {
    const { stdout } = await execFileAsync2(
      "sh",
      ["-c", `${command} "$1"`, "openzca-voice-publish", filePath],
      {
        encoding: "utf8",
        maxBuffer: 10 * 1024 * 1024
      }
    );
    return extractPublishedVoiceUrl(stdout);
  } catch (error) {
    if (error instanceof Error && "stderr" in error) {
      const stderr = typeof error.stderr === "string" ? error.stderr.trim() : "";
      throw new Error(
        stderr ? `Voice publish command failed: ${stderr}` : `Voice publish command failed: ${error.message}`
      );
    }
    throw error;
  }
}

// src/lib/reply.ts
import { ThreadType as ThreadType2 } from "zca-js";
function prepareReplyMessage(value, params) {
  const sourceRecord = asReplyMessageRecord(value);
  const metadata = asOptionalReplyMessageRecord(sourceRecord.metadata);
  const rawMessageRecord = asOptionalReplyMessageRecord(sourceRecord.rawMessage);
  const rawPayloadRecord = asOptionalReplyMessageRecord(sourceRecord.rawPayload);
  const canonicalRecord = rawMessageRecord ?? sourceRecord;
  const content = parseReplyMessageContent(
    canonicalRecord.content ?? sourceRecord.content,
    isLikelyOpenzcaListenPayload(sourceRecord) && !rawMessageRecord
  );
  const msgType = requireStringLike(
    [canonicalRecord.msgType, sourceRecord.msgType, metadata?.msgType],
    "reply message msgType"
  );
  const uidFrom = requireStringLike(
    [
      canonicalRecord.uidFrom,
      sourceRecord.uidFrom,
      sourceRecord.senderId,
      sourceRecord.fromId,
      metadata?.senderId,
      metadata?.fromId
    ],
    "reply message uidFrom"
  );
  const msgId = requireStringLike(
    [canonicalRecord.msgId, sourceRecord.msgId, rawPayloadRecord?.msgId],
    "reply message msgId"
  );
  const cliMsgId = requireStringLike(
    [canonicalRecord.cliMsgId, sourceRecord.cliMsgId, rawPayloadRecord?.cliMsgId],
    "reply message cliMsgId"
  );
  const ts = requireTsString(
    [canonicalRecord.ts, sourceRecord.ts, maybeTimestampSecondsToMsString(sourceRecord.timestamp)],
    "reply message ts"
  );
  const ttl = parseReplyMessageTtl(canonicalRecord.ttl ?? sourceRecord.ttl);
  const propertyExt = parseReplyMessagePropertyExt(canonicalRecord.propertyExt);
  return {
    quote: {
      content,
      msgType,
      propertyExt,
      uidFrom,
      msgId,
      cliMsgId,
      ts,
      ttl
    },
    inferredThreadId: inferReplyMessageThreadId({
      sourceRecord,
      canonicalRecord,
      metadata,
      threadType: params?.threadType,
      selfId: params?.selfId
    })
  };
}
function prepareStoredReplyMessage(value, params) {
  const record = asReplyMessageRecord(value);
  const storedThreadType = record.threadType === "group" ? ThreadType2.Group : record.threadType === "user" ? ThreadType2.User : void 0;
  if (storedThreadType !== void 0 && storedThreadType !== params.threadType) {
    throw new Error("Reply source thread type does not match --group.");
  }
  const storedThreadId = firstString2([record.threadId, record.rawThreadId]) ?? void 0;
  if (storedThreadId && storedThreadId !== params.threadId) {
    throw new Error("Reply source belongs to a different thread.");
  }
  const rawMessage = asOptionalReplyMessageRecord(record.rawMessage);
  const rawPayload = asOptionalReplyMessageRecord(record.rawPayload);
  const replyRecord = rawMessage ?? rawPayload;
  if (!replyRecord) {
    throw new Error(
      "Reply source found in DB but has no reusable raw message payload. Re-sync or capture it via listener first."
    );
  }
  return prepareReplyMessage(replyRecord, {
    threadType: params.threadType,
    selfId: params.selfId
  }).quote;
}
function asReplyMessageRecord(value) {
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    throw new Error("Reply message must be a JSON object matching the raw message.data shape.");
  }
  return value;
}
function asOptionalReplyMessageRecord(value) {
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    return void 0;
  }
  return value;
}
function parseReplyMessageContent(value, stripOpenzcaDecorations) {
  if (typeof value === "string") {
    return stripOpenzcaDecorations ? stripEnrichedReplyDecorations(value) : value;
  }
  if (value && typeof value === "object" && !Array.isArray(value)) {
    return value;
  }
  throw new Error("Reply message content must be a string or object.");
}
function stripEnrichedReplyDecorations(value) {
  const lines = value.split("\n");
  while (lines.length > 0) {
    const last = lines[lines.length - 1].trim();
    if (last.startsWith("[reply context: ") || last.startsWith("[reply media attached:") || last.startsWith("[reply media attached ")) {
      lines.pop();
      continue;
    }
    break;
  }
  return lines.join("\n");
}
function parseReplyMessagePropertyExt(value) {
  if (value === void 0) {
    return void 0;
  }
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    throw new Error("Reply message propertyExt must be an object when provided.");
  }
  return value;
}
function parseReplyMessageTtl(value) {
  if (value === void 0 || value === null || value === "") {
    return 0;
  }
  const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
  if (!Number.isFinite(parsed)) {
    throw new Error("Reply message ttl must be a finite number.");
  }
  return Math.trunc(parsed);
}
function requireStringLike(values, label) {
  const value = firstString2(values);
  if (!value) {
    throw new Error(`Missing ${label}.`);
  }
  return value;
}
function requireTsString(values, label) {
  for (const value of values) {
    if (typeof value === "string" && value.trim()) {
      return value.trim();
    }
    if (typeof value === "number" && Number.isFinite(value)) {
      return String(Math.trunc(value));
    }
  }
  throw new Error(`Missing ${label}.`);
}
function firstString2(values) {
  for (const value of values) {
    if (typeof value === "string" && value.trim()) {
      return value.trim();
    }
    if (typeof value === "number" && Number.isFinite(value)) {
      return String(Math.trunc(value));
    }
  }
  return void 0;
}
function maybeTimestampSecondsToMsString(value) {
  if (typeof value !== "number" || !Number.isFinite(value)) {
    return void 0;
  }
  return String(Math.trunc(value * 1e3));
}
function isLikelyOpenzcaListenPayload(record) {
  return typeof record.threadId === "string" && (typeof record.senderId === "string" || typeof record.chatType === "string" || typeof record.metadata === "object");
}
function inferReplyMessageThreadId(params) {
  const directThreadId = firstString2([
    params.sourceRecord.threadId,
    params.sourceRecord.targetId,
    params.sourceRecord.conversationId,
    params.metadata?.threadId,
    params.metadata?.targetId
  ]);
  if (directThreadId) {
    return directThreadId;
  }
  if (params.threadType === void 0) {
    return void 0;
  }
  const idTo = firstString2([
    params.canonicalRecord.idTo,
    params.sourceRecord.idTo,
    params.sourceRecord.toId,
    params.metadata?.toId
  ]);
  if (params.threadType === ThreadType2.Group) {
    return idTo;
  }
  const uidFrom = firstString2([
    params.canonicalRecord.uidFrom,
    params.sourceRecord.uidFrom,
    params.sourceRecord.senderId,
    params.sourceRecord.fromId,
    params.metadata?.senderId,
    params.metadata?.fromId
  ]);
  if (!uidFrom && !idTo) {
    return void 0;
  }
  if (params.selfId) {
    if (uidFrom && uidFrom !== params.selfId && uidFrom !== "0") {
      return uidFrom;
    }
    if (idTo && idTo !== params.selfId && idTo !== "0") {
      return idTo;
    }
  }
  if (uidFrom && uidFrom !== "0") {
    return uidFrom;
  }
  if (idTo && idTo !== "0") {
    return idTo;
  }
  return void 0;
}

// src/lib/adaptive-batch.ts
var RETRYABLE_LOOKUP_ERROR_PATTERNS = [
  /retry limit/i,
  /\brate limit/i,
  /\btoo many requests?\b/i,
  /\btimeout\b/i,
  /\btimed out\b/i,
  /\betimedout\b/i,
  /\beconnreset\b/i,
  /\besockettimedout\b/i,
  /\bsocket hang up\b/i,
  /\btemporar(?:y|ily)\b/i
];
var SPLITTABLE_LOOKUP_ERROR_PATTERNS = [
  /\binvalid param(?:eter)?s?\b/i,
  /\binvalid request\b/i,
  /\bbad request\b/i,
  /tham so khong hop le/i,
  /tham số không hợp lệ/i
];
function sleep2(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}
function chunkKeys(keys, size) {
  const chunkSize = Math.max(1, Math.trunc(size) || 1);
  const chunks = [];
  for (let index = 0; index < keys.length; index += chunkSize) {
    chunks.push(keys.slice(index, index + chunkSize));
  }
  return chunks;
}
function toErrorText(error) {
  return error instanceof Error ? error.message : String(error);
}
function isRetryableLookupError(error) {
  const message = toErrorText(error);
  return RETRYABLE_LOOKUP_ERROR_PATTERNS.some((pattern) => pattern.test(message));
}
function isSplittableLookupError(error) {
  const message = toErrorText(error);
  return SPLITTABLE_LOOKUP_ERROR_PATTERNS.some((pattern) => pattern.test(message));
}
async function runAdaptiveBatch(keys, options) {
  const maxRetries = Math.max(0, options.maxRetries ?? 2);
  const initialDelayMs = Math.max(0, options.retryDelayMs ?? 400);
  const backoffMultiplier = Math.max(1, options.backoffMultiplier ?? 2);
  const shouldRetry = options.shouldRetry ?? isRetryableLookupError;
  const shouldSplit = options.shouldSplit ?? isSplittableLookupError;
  let attempt = 0;
  let delayMs = initialDelayMs;
  while (true) {
    try {
      return await options.fetchBatch(keys) ?? {};
    } catch (error) {
      if (keys.length > 1 && shouldSplit(error)) {
        throw error;
      }
      attempt += 1;
      if (attempt > maxRetries || !shouldRetry(error)) {
        throw error;
      }
      await options.onRetry?.({
        keys: [...keys],
        attempt,
        maxRetries,
        delayMs,
        error
      });
      if (delayMs > 0) {
        await sleep2(delayMs);
      }
      delayMs = Math.max(delayMs * backoffMultiplier, delayMs + 1);
    }
  }
}
async function fetchAdaptiveObjectBatches(keys, options) {
  const uniqueKeys = Array.from(new Set(keys.map((value) => value.trim()).filter(Boolean)));
  const pending = chunkKeys(uniqueKeys, options.initialBatchSize ?? 5);
  const values = /* @__PURE__ */ new Map();
  const errors = [];
  const shouldRetry = options.shouldRetry ?? isRetryableLookupError;
  const shouldSplit = options.shouldSplit ?? isSplittableLookupError;
  const continueOnItemError = options.continueOnItemError ?? true;
  const batchDelayMs = Math.max(0, options.batchDelayMs ?? 75);
  while (pending.length > 0) {
    const batch = pending.shift();
    if (!batch || batch.length === 0) {
      continue;
    }
    try {
      const result = await runAdaptiveBatch(batch, options);
      for (const key of batch) {
        const value = result[key];
        if (value !== void 0) {
          values.set(key, value);
        }
      }
    } catch (error) {
      if (batch.length > 1 && (shouldSplit(error) || shouldRetry(error))) {
        pending.unshift(...chunkKeys(batch, Math.ceil(batch.length / 2)));
        continue;
      }
      if (!continueOnItemError || batch.length > 1) {
        throw error;
      }
      const itemError = { key: batch[0], error };
      errors.push(itemError);
      await options.onItemError?.(itemError);
      continue;
    }
    if (batchDelayMs > 0 && pending.length > 0) {
      await sleep2(batchDelayMs);
    }
  }
  return { values, errors };
}

// src/cli.ts
var require3 = createRequire2(import.meta.url);
var { version: PKG_VERSION } = require3("../package.json");
var program = new Command();
var EMOJI_REACTION_MAP = {
  "\u2764\uFE0F": Reactions.HEART,
  "\u2764": Reactions.HEART,
  "\u{1F44D}": Reactions.LIKE,
  "\u{1F606}": Reactions.HAHA,
  "\u{1F602}": Reactions.HAHA,
  "\u{1F62E}": Reactions.WOW,
  "\u{1F62D}": Reactions.CRY,
  "\u{1F621}": Reactions.ANGRY
};
var DEBUG_COMMAND_START = /* @__PURE__ */ new WeakMap();
function parseDebugFlag(value) {
  if (!value) return false;
  const normalized = value.trim().toLowerCase();
  return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
}
function getActionCommand(args) {
  for (let index = args.length - 1; index >= 0; index -= 1) {
    const item = args[index];
    if (item instanceof Command) {
      return item;
    }
  }
  return void 0;
}
function commandPathLabel(command) {
  if (!command) return void 0;
  const names = [];
  let current = command;
  while (current) {
    const name = current.name();
    if (name) {
      names.unshift(name);
    }
    current = current.parent ?? null;
  }
  return names.join(" ");
}
function readCliFlag(names) {
  const argv = process.argv.slice(2);
  return argv.some((item) => names.includes(item));
}
function readCliOptionValue(names) {
  const argv = process.argv.slice(2);
  for (let index = argv.length - 1; index >= 0; index -= 1) {
    const item = argv[index];
    for (const name of names) {
      if (item === name) {
        return argv[index + 1];
      }
      if (item.startsWith(`${name}=`)) {
        return item.slice(name.length + 1);
      }
    }
  }
  return void 0;
}
function getDebugOptions(command) {
  if (command) {
    if (typeof command.optsWithGlobals === "function") {
      return command.optsWithGlobals();
    }
    return command.opts();
  }
  if (typeof program.optsWithGlobals === "function") {
    return program.optsWithGlobals();
  }
  return program.opts();
}
function resolveDebugEnabled(command) {
  if (parseDebugFlag(process.env.OPENZCA_DEBUG)) {
    return true;
  }
  return Boolean(getDebugOptions(command).debug);
}
function resolveDebugFilePath(command) {
  const options = getDebugOptions(command);
  const configured = options.debugFile?.trim() || process.env.OPENZCA_DEBUG_FILE?.trim() || path7.join(APP_HOME, "logs", "openzca-debug.log");
  const normalized = normalizeMediaInput(configured);
  return path7.isAbsolute(normalized) ? normalized : path7.resolve(process.cwd(), normalized);
}
function writeDebugLine(event, details, command) {
  if (!resolveDebugEnabled(command)) {
    return;
  }
  const payload = details ? JSON.stringify(details) : "";
  const line = `${(/* @__PURE__ */ new Date()).toISOString()} ${event}${payload ? ` ${payload}` : ""}
`;
  const filePath = resolveDebugFilePath(command);
  try {
    fsSync.mkdirSync(path7.dirname(filePath), { recursive: true });
    fsSync.appendFileSync(filePath, line, "utf8");
  } catch {
  }
}
function wrapAction(handler) {
  return async (...args) => {
    const command = getActionCommand(args);
    try {
      await handler(...args);
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      writeDebugLine(
        "command.error",
        {
          command: commandPathLabel(command),
          message,
          stack: error instanceof Error ? error.stack : void 0
        },
        command
      );
      console.error(`Error: ${message}`);
      process.exitCode = 1;
    }
  };
}
function output(value, asJson = false) {
  if (asJson) {
    console.log(JSON.stringify(value, null, 2));
    return;
  }
  if (Array.isArray(value)) {
    if (value.length === 0) {
      console.log("(empty)");
      return;
    }
    const head = value[0];
    if (head && typeof head === "object" && !Array.isArray(head)) {
      console.table(value);
      return;
    }
  }
  if (value && typeof value === "object") {
    console.log(util.inspect(value, { colors: false, depth: 6 }));
    return;
  }
  console.log(String(value));
}
function shouldOutputJson(opts) {
  return Boolean(opts?.json) || readCliFlag(["--json", "-j"]);
}
function normalizeCommandAliases(argv) {
  const normalized = [...argv];
  const dbIndex = normalized.indexOf("db");
  if (dbIndex === -1 || normalized[dbIndex + 1] !== "chat") {
    return normalized;
  }
  const subcommandOrId = normalized[dbIndex + 2];
  if (!subcommandOrId || subcommandOrId.startsWith("-")) {
    return normalized;
  }
  if (["list", "info", "messages", "help"].includes(subcommandOrId)) {
    return normalized;
  }
  normalized.splice(dbIndex + 2, 0, "messages");
  return normalized;
}
function asThreadType(groupFlag) {
  return groupFlag ? ThreadType3.Group : ThreadType3.User;
}
function parseBooleanFromEnv(name, fallback) {
  const raw = process.env[name]?.trim();
  if (!raw) return fallback;
  const normalized = raw.toLowerCase();
  if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") {
    return true;
  }
  if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") {
    return false;
  }
  return fallback;
}
function normalizeCachedId(value) {
  if (typeof value === "string") {
    const trimmed = value.trim();
    return trimmed.length > 0 ? trimmed : null;
  }
  if (typeof value === "number" && Number.isFinite(value)) {
    return String(Math.trunc(value));
  }
  return null;
}
function collectIdsFromCacheEntries(entries, keys) {
  const ids = /* @__PURE__ */ new Set();
  for (const entry of entries) {
    if (!entry || typeof entry !== "object") continue;
    const row = entry;
    for (const key of keys) {
      const normalized = normalizeCachedId(row[key]);
      if (normalized) {
        ids.add(normalized);
      }
    }
  }
  return ids;
}
function getListenerOwnerLockPath(profile) {
  return path7.join(getProfileDir(profile), "listener-owner.json");
}
function getListenIpcSocketPath(profile) {
  if (process.platform === "win32") {
    const safe = profile.replace(/[^A-Za-z0-9_-]/g, "_");
    return `\\\\.\\pipe\\openzca-listen-${safe}`;
  }
  return path7.join(getProfileDir(profile), "listen.sock");
}
function parsePositiveIntFromUnknown(value) {
  if (typeof value === "number" && Number.isFinite(value) && value > 0) {
    return Math.trunc(value);
  }
  if (typeof value === "string" && value.trim().length > 0) {
    const parsed = Number.parseInt(value.trim(), 10);
    if (Number.isFinite(parsed) && parsed > 0) {
      return parsed;
    }
  }
  return null;
}
function retrySendMethod(operation, command, metaBuilder) {
  const config = getSendRetryConfigFromEnv();
  return retryable(operation, {
    ...config,
    onRetry: ({ attempt, maxRetries, delayMs, error, args }) => {
      writeDebugLine(
        "send.retry",
        {
          ...metaBuilder(...args),
          attempt,
          maxRetries,
          delayMs,
          message: error instanceof Error ? error.message : String(error)
        },
        command
      );
    }
  });
}
var LOOKUP_BATCH_SIZE = 5;
var LOOKUP_RETRY_COUNT = 2;
var LOOKUP_RETRY_DELAY_MS = 400;
var LOOKUP_BATCH_DELAY_MS = 75;
async function fetchGroupInfoRecords(api, groupIds) {
  const { values } = await fetchAdaptiveObjectBatches(groupIds, {
    fetchBatch: async (keys) => {
      const response = await api.getGroupInfo(keys);
      return response.gridInfoMap ?? {};
    },
    initialBatchSize: LOOKUP_BATCH_SIZE,
    maxRetries: LOOKUP_RETRY_COUNT,
    retryDelayMs: LOOKUP_RETRY_DELAY_MS,
    batchDelayMs: LOOKUP_BATCH_DELAY_MS
  });
  return values;
}
async function fetchGroupInfoRecord(api, groupId) {
  const groups = await fetchGroupInfoRecords(api, [groupId]);
  const group2 = groups.get(groupId);
  if (!group2) {
    throw new Error(`Group not found: ${groupId}`);
  }
  return group2;
}
async function fetchGroupMemberProfiles(api, memberIds) {
  const { values } = await fetchAdaptiveObjectBatches(memberIds, {
    fetchBatch: async (keys) => {
      const response = await api.getGroupMembersInfo(keys);
      return response.profiles ?? {};
    },
    initialBatchSize: LOOKUP_BATCH_SIZE,
    maxRetries: LOOKUP_RETRY_COUNT,
    retryDelayMs: LOOKUP_RETRY_DELAY_MS,
    batchDelayMs: LOOKUP_BATCH_DELAY_MS
  });
  return values;
}
function isProcessAlive(pid) {
  if (!Number.isInteger(pid) || pid <= 0) return false;
  try {
    process.kill(pid, 0);
    return true;
  } catch (error) {
    const code = error.code;
    if (code === "EPERM") return true;
    return false;
  }
}
async function readListenerOwnerRecord(lockPath) {
  try {
    const raw = await fs7.readFile(lockPath, "utf8");
    const parsed = JSON.parse(raw);
    const pid = parsePositiveIntFromUnknown(parsed.pid);
    if (!pid) return null;
    return {
      pid,
      profile: String(parsed.profile ?? ""),
      sessionId: typeof parsed.sessionId === "string" ? parsed.sessionId : void 0,
      startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : ""
    };
  } catch (error) {
    const code = error.code;
    if (code === "ENOENT") return null;
    throw error;
  }
}
async function readActiveListenerOwner(profile) {
  const lockPath = getListenerOwnerLockPath(profile);
  const record = await readListenerOwnerRecord(lockPath);
  if (!record) {
    await fs7.rm(lockPath, { force: true });
    return null;
  }
  if (!isProcessAlive(record.pid)) {
    await fs7.rm(lockPath, { force: true });
    return null;
  }
  return record;
}
async function acquireListenerOwnerLock(profile, sessionId, command) {
  await ensureProfile(profile);
  const lockPath = getListenerOwnerLockPath(profile);
  const record = {
    pid: process.pid,
    profile,
    sessionId,
    startedAt: (/* @__PURE__ */ new Date()).toISOString()
  };
  for (let attempt = 0; attempt < 3; attempt += 1) {
    try {
      await fs7.writeFile(lockPath, `${JSON.stringify(record, null, 2)}
`, {
        encoding: "utf8",
        flag: "wx"
      });
      let released = false;
      return {
        lockPath,
        release: async () => {
          if (released) return;
          released = true;
          const current = await readListenerOwnerRecord(lockPath);
          if (current && current.pid !== process.pid) return;
          await fs7.rm(lockPath, { force: true });
          writeDebugLine(
            "listen.owner.released",
            {
              profile,
              lockPath,
              pid: process.pid
            },
            command
          );
        }
      };
    } catch (error) {
      const code = error.code;
      if (code !== "EEXIST") throw error;
      const owner = await readActiveListenerOwner(profile);
      if (owner) {
        throw new Error(
          `Another openzca listener already owns profile "${profile}" (pid ${owner.pid}).`
        );
      }
      await fs7.rm(lockPath, { force: true });
    }
  }
  throw new Error(`Unable to acquire listener ownership for profile "${profile}".`);
}
async function startListenerIpcServer(api, profile, sessionId, command) {
  if (!parseBooleanFromEnv("OPENZCA_LISTEN_IPC", true)) {
    writeDebugLine(
      "listen.ipc.disabled",
      {
        profile
      },
      command
    );
    return null;
  }
  const socketPath = getListenIpcSocketPath(profile);
  if (process.platform !== "win32") {
    await fs7.rm(socketPath, { force: true });
  }
  const uploadTimeoutMs = parsePositiveIntFromEnv(
    "OPENZCA_UPLOAD_IPC_HANDLER_TIMEOUT_MS",
    parsePositiveIntFromEnv("OPENZCA_UPLOAD_TIMEOUT_MS", 12e4)
  );
  const server = net.createServer((socket) => {
    socket.setEncoding("utf8");
    let buffer = "";
    let done = false;
    const sendResponse = (response) => {
      if (done) return;
      done = true;
      socket.end(`${JSON.stringify(response)}
`);
    };
    const fail = (requestId, message) => {
      sendResponse({
        kind: "upload_result",
        requestId,
        ok: false,
        error: message
      });
    };
    const handleRequest = async (line) => {
      if (done) return;
      let parsed;
      try {
        parsed = JSON.parse(line);
      } catch {
        fail("", "Invalid JSON request.");
        return;
      }
      if (parsed.kind !== "upload") {
        fail(parsed.requestId || "", "Unsupported IPC request kind.");
        return;
      }
      if (parsed.profile !== profile) {
        fail(parsed.requestId, "Profile mismatch.");
        return;
      }
      if (!parsed.threadId || !Array.isArray(parsed.attachments) || parsed.attachments.length === 0) {
        fail(parsed.requestId, "Invalid upload payload.");
        return;
      }
      const threadType = parsed.threadType === "group" ? ThreadType3.Group : ThreadType3.User;
      const requestTimeoutMs = parsePositiveIntFromUnknown(parsed.uploadTimeoutMs) ?? uploadTimeoutMs;
      const sendMessage = retrySendMethod(
        api.sendMessage.bind(api),
        command,
        (_payload, threadId, threadTypeArg) => ({
          kind: "listen.ipc.upload",
          threadId,
          threadType: threadTypeArg === ThreadType3.Group ? "group" : "user"
        })
      );
      writeDebugLine(
        "listen.ipc.upload.start",
        {
          profile,
          sessionId,
          requestId: parsed.requestId,
          threadId: parsed.threadId,
          threadType: parsed.threadType,
          attachmentCount: parsed.attachments.length,
          timeoutMs: requestTimeoutMs
        },
        command
      );
      try {
        const response = await withTimeout(
          sendMessage(
            {
              msg: "",
              attachments: parsed.attachments
            },
            parsed.threadId,
            threadType
          ),
          requestTimeoutMs,
          `Timed out waiting ${requestTimeoutMs}ms for IPC upload completion.`
        );
        sendResponse({
          kind: "upload_result",
          requestId: parsed.requestId,
          ok: true,
          response
        });
        writeDebugLine(
          "listen.ipc.upload.done",
          {
            profile,
            sessionId,
            requestId: parsed.requestId,
            threadId: parsed.threadId,
            threadType: parsed.threadType
          },
          command
        );
      } catch (error) {
        fail(parsed.requestId, toErrorText2(error));
        writeDebugLine(
          "listen.ipc.upload.error",
          {
            profile,
            sessionId,
            requestId: parsed.requestId,
            threadId: parsed.threadId,
            threadType: parsed.threadType,
            message: toErrorText2(error)
          },
          command
        );
      }
    };
    socket.on("data", (chunk) => {
      if (done) return;
      buffer += chunk;
      if (buffer.length > 2e6) {
        fail("", "IPC request too large.");
        return;
      }
      const newlineIndex = buffer.indexOf("\n");
      if (newlineIndex === -1) return;
      const line = buffer.slice(0, newlineIndex).trim();
      buffer = "";
      if (!line) {
        fail("", "Empty IPC request.");
        return;
      }
      void handleRequest(line);
    });
    socket.on("error", (error) => {
      writeDebugLine(
        "listen.ipc.socket_error",
        {
          profile,
          sessionId,
          message: toErrorText2(error)
        },
        command
      );
    });
  });
  server.on("error", (error) => {
    writeDebugLine(
      "listen.ipc.server_error",
      {
        profile,
        sessionId,
        message: toErrorText2(error)
      },
      command
    );
  });
  await new Promise((resolve, reject) => {
    server.once("error", reject);
    server.listen(socketPath, () => {
      server.off("error", reject);
      resolve();
    });
  });
  writeDebugLine(
    "listen.ipc.started",
    {
      profile,
      sessionId,
      socketPath
    },
    command
  );
  let closed = false;
  return {
    socketPath,
    close: async () => {
      if (closed) return;
      closed = true;
      await new Promise((resolve) => {
        server.close(() => resolve());
      });
      if (process.platform !== "win32") {
        await fs7.rm(socketPath, { force: true });
      }
      writeDebugLine(
        "listen.ipc.stopped",
        {
          profile,
          sessionId,
          socketPath
        },
        command
      );
    }
  };
}
async function tryUploadViaListenerIpc(profile, threadId, threadType, attachments, command) {
  if (!parseBooleanFromEnv("OPENZCA_UPLOAD_IPC", true)) {
    return { handled: false, reason: "ipc_disabled" };
  }
  const socketPath = getListenIpcSocketPath(profile);
  const requestId = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 10)}`;
  const connectTimeoutMs = parsePositiveIntFromEnv("OPENZCA_UPLOAD_IPC_CONNECT_TIMEOUT_MS", 1e3);
  const requestTimeoutMs = parsePositiveIntFromEnv(
    "OPENZCA_UPLOAD_IPC_TIMEOUT_MS",
    parsePositiveIntFromEnv("OPENZCA_UPLOAD_TIMEOUT_MS", 12e4) + 5e3
  );
  writeDebugLine(
    "msg.upload.ipc.try",
    {
      profile,
      threadId,
      threadType: threadType === ThreadType3.Group ? "group" : "user",
      attachmentCount: attachments.length,
      socketPath,
      requestId,
      connectTimeoutMs,
      requestTimeoutMs
    },
    command
  );
  return await new Promise((resolve, reject) => {
    const socket = net.createConnection(socketPath);
    let connected = false;
    let settled = false;
    let responseBuffer = "";
    let requestSent = false;
    const finish = (result, error) => {
      if (settled) return;
      settled = true;
      clearTimeout(connectTimer);
      clearTimeout(requestTimer);
      if (!socket.destroyed) {
        socket.destroy();
      }
      if (error) {
        reject(error);
        return;
      }
      resolve(result ?? { handled: false, reason: "unknown" });
    };
    const connectTimer = setTimeout(() => {
      finish(void 0, new Error(`Timed out waiting ${connectTimeoutMs}ms to connect upload IPC.`));
    }, connectTimeoutMs);
    const requestTimer = setTimeout(() => {
      finish(void 0, new Error(`Timed out waiting ${requestTimeoutMs}ms for upload IPC response.`));
    }, requestTimeoutMs);
    socket.setEncoding("utf8");
    socket.on("connect", () => {
      connected = true;
      clearTimeout(connectTimer);
      const payload = {
        kind: "upload",
        requestId,
        profile,
        threadId,
        threadType: threadType === ThreadType3.Group ? "group" : "user",
        attachments
      };
      socket.write(`${JSON.stringify(payload)}
`);
      requestSent = true;
    });
    socket.on("data", (chunk) => {
      responseBuffer += chunk;
      const newlineIndex = responseBuffer.indexOf("\n");
      if (newlineIndex === -1) return;
      const line = responseBuffer.slice(0, newlineIndex).trim();
      if (!line) {
        finish(void 0, new Error("Upload IPC returned empty response."));
        return;
      }
      let parsed;
      try {
        parsed = JSON.parse(line);
      } catch {
        finish(void 0, new Error("Upload IPC returned invalid JSON."));
        return;
      }
      if (parsed.kind !== "upload_result" || parsed.requestId !== requestId) {
        finish(void 0, new Error("Upload IPC returned mismatched response."));
        return;
      }
      if (!parsed.ok) {
        finish(void 0, new Error(parsed.error || "Upload IPC failed."));
        return;
      }
      finish({
        handled: true,
        response: parsed.response
      });
    });
    socket.on("error", (error) => {
      const code = error.code;
      if (!connected && !requestSent && (code === "ENOENT" || code === "ECONNREFUSED")) {
        finish({
          handled: false,
          reason: code.toLowerCase()
        });
        return;
      }
      finish(void 0, error);
    });
    socket.on("close", () => {
      if (settled) return;
      if (!connected && !requestSent) {
        finish({
          handled: false,
          reason: "socket_closed_before_connect"
        });
        return;
      }
      finish(void 0, new Error("Upload IPC connection closed before response."));
    });
  });
}
async function resolveUploadThreadType(api, profile, threadId, groupFlag, command) {
  if (groupFlag) {
    return { type: ThreadType3.Group, reason: "explicit_group_flag" };
  }
  const autoDetectEnabled = parseBooleanFromEnv("OPENZCA_UPLOAD_AUTO_THREAD_TYPE", false);
  if (!autoDetectEnabled) {
    return { type: ThreadType3.User, reason: "auto_detect_disabled" };
  }
  try {
    const cache = await readCache(profile);
    const groupIds = collectIdsFromCacheEntries(cache.groups, ["groupId", "grid", "threadId", "id"]);
    if (groupIds.has(threadId)) {
      return { type: ThreadType3.Group, reason: "cache_group_match" };
    }
    const friendIds = collectIdsFromCacheEntries(cache.friends, ["userId", "uid", "id", "threadId"]);
    if (friendIds.has(threadId)) {
      return { type: ThreadType3.User, reason: "cache_friend_match" };
    }
  } catch (error) {
    writeDebugLine(
      "msg.upload.thread_type.cache_error",
      {
        profile,
        threadId,
        message: toErrorText2(error)
      },
      command
    );
  }
  const probeEnabled = parseBooleanFromEnv("OPENZCA_UPLOAD_GROUP_PROBE", true);
  if (!probeEnabled) {
    return { type: ThreadType3.User, reason: "probe_disabled" };
  }
  const probeTimeoutMs = parsePositiveIntFromEnv("OPENZCA_UPLOAD_GROUP_PROBE_TIMEOUT_MS", 5e3);
  try {
    const groupInfo = await withTimeout(
      api.getGroupInfo(threadId),
      probeTimeoutMs,
      `Timed out waiting ${probeTimeoutMs}ms while probing group thread type.`
    );
    if (groupInfo?.gridInfoMap?.[threadId]) {
      return { type: ThreadType3.Group, reason: "probe_group_match" };
    }
  } catch (error) {
    writeDebugLine(
      "msg.upload.thread_type.probe_error",
      {
        profile,
        threadId,
        message: toErrorText2(error)
      },
      command
    );
  }
  return { type: ThreadType3.User, reason: "default_user" };
}
function parseReaction(input) {
  const normalized = input.trim();
  if (EMOJI_REACTION_MAP[normalized]) {
    return EMOJI_REACTION_MAP[normalized];
  }
  const enumValue = Reactions[normalized.toUpperCase().replace(/[\s-]/g, "_")];
  if (enumValue) {
    return enumValue;
  }
  const values = new Set(Object.values(Reactions));
  if (values.has(normalized)) {
    return normalized;
  }
  throw new Error(
    `Unsupported reaction "${input}". Use emoji (e.g. \u2764\uFE0F) or one of Reactions enum values.`
  );
}
function formatDateOnly(input) {
  const y = input.getUTCFullYear();
  const m = String(input.getUTCMonth() + 1).padStart(2, "0");
  const d = String(input.getUTCDate()).padStart(2, "0");
  return `${y}-${m}-${d}`;
}
function normalizeAccountInfo(rawValue) {
  const raw = rawValue;
  const profileCandidate = raw && typeof raw === "object" && raw.profile && typeof raw.profile === "object" ? raw.profile : raw ?? {};
  const userId = String(
    profileCandidate.userId ?? profileCandidate.uid ?? profileCandidate.userKey ?? profileCandidate.id ?? ""
  ) || "";
  const displayName = String(
    profileCandidate.displayName ?? profileCandidate.zaloName ?? profileCandidate.username ?? profileCandidate.name ?? ""
  ) || "";
  return {
    raw: rawValue,
    profile: profileCandidate,
    userId,
    displayName
  };
}
function normalizeMeInfoOutput(rawValue) {
  const info = normalizeAccountInfo(rawValue);
  const avatar = getStringCandidate(info.profile, [
    "avatar",
    "avatarUrl",
    "avatar_url",
    "thumbSrc",
    "thumb"
  ]);
  return {
    ...info.profile,
    userId: info.userId || void 0,
    displayName: info.displayName || void 0,
    avatar: avatar || void 0
  };
}
async function currentProfile(_command) {
  const opts = program.opts();
  return resolveProfileName(opts.profile);
}
async function profileForLogin() {
  const opts = program.opts();
  const explicit = opts.profile?.trim();
  const fromEnv = process.env.OPENZCA_PROFILE?.trim() || process.env.ZCA_PROFILE?.trim();
  if (explicit) {
    await ensureProfile(explicit);
    return explicit;
  }
  if (fromEnv) {
    await ensureProfile(fromEnv);
    return fromEnv;
  }
  const db = await listProfiles();
  const fallback = db.defaultProfile || "default";
  await ensureProfile(fallback);
  return fallback;
}
async function requireApi(command, options) {
  const profile = await currentProfile(command);
  const api = await loginWithStoredCredentials(profile, options);
  return { profile, api };
}
function toDbThreadType(groupFlag) {
  return groupFlag ? "group" : "user";
}
function getDbWriteOverride(opts) {
  if (!opts || typeof opts.db !== "boolean") {
    return void 0;
  }
  return opts.db;
}
async function shouldWriteToDb(profile, override) {
  if (typeof override === "boolean") {
    return override;
  }
  return isDbEnabled(profile);
}
async function resolveSendReplyQuote(params) {
  const replyId = params.replyId?.trim();
  const replyMessage = params.replyMessage?.trim();
  if (replyId && replyMessage) {
    throw new Error("Use either --reply-id or --reply-message, not both.");
  }
  if (!replyId && !replyMessage) {
    return void 0;
  }
  if (replyId) {
    if (!await shouldWriteToDb(params.profile)) {
      throw new Error("`--reply-id` requires the local DB. Enable DB/listen sync first.");
    }
    const row = await getMessageById({
      profile: params.profile,
      id: replyId
    });
    if (!row) {
      throw new Error(`Reply source not found in DB: ${replyId}`);
    }
    if (row.threadType === "group" !== (params.threadType === ThreadType3.Group)) {
      throw new Error("Reply source thread type does not match --group.");
    }
    if (row.threadId !== params.threadId) {
      throw new Error("Reply source belongs to a different thread.");
    }
    if (!row.rawMessage || typeof row.rawMessage !== "object") {
      if (!row.rawPayload || typeof row.rawPayload !== "object") {
        throw new Error(
          "Reply source found in DB but has no reusable raw message payload. Re-sync or capture it via listener first."
        );
      }
    }
    return prepareStoredReplyMessage(row, {
      threadId: params.threadId,
      threadType: params.threadType,
      selfId: params.api.getOwnId()
    });
  }
  let parsedReplyMessage;
  try {
    parsedReplyMessage = JSON.parse(replyMessage);
  } catch (error) {
    throw new Error(
      `Invalid JSON for --reply-message: ${error instanceof Error ? error.message : String(error)}`
    );
  }
  const preparedReply = prepareReplyMessage(parsedReplyMessage, {
    threadType: params.threadType,
    selfId: params.api.getOwnId()
  });
  if (preparedReply.inferredThreadId && preparedReply.inferredThreadId !== params.threadId) {
    throw new Error("Reply message belongs to a different thread.");
  }
  return preparedReply.quote;
}
function scheduleDbWrite(profile, command, event, task) {
  enqueueDbWrite(profile, async () => {
    try {
      await task();
    } catch (error) {
      writeDebugLine(
        event,
        {
          profile,
          message: error instanceof Error ? error.message : String(error)
        },
        command
      );
    }
  });
}
function extractResponseMessageIds(value) {
  const ids = /* @__PURE__ */ new Set();
  const visit = (item) => {
    if (!item) return;
    if (Array.isArray(item)) {
      for (const nested of item) {
        visit(nested);
      }
      return;
    }
    if (typeof item !== "object") {
      return;
    }
    const record = item;
    const msgId = normalizeCachedId(record.msgId);
    if (msgId) {
      ids.add(msgId);
    }
    for (const key of ["message", "attachment", "attachments", "results", "response"]) {
      if (key in record) {
        visit(record[key]);
      }
    }
  };
  visit(value);
  return Array.from(ids);
}
async function persistOutgoingMessageBestEffort(params) {
  const selfId = params.api.getOwnId();
  const threadType = toDbThreadType(params.group);
  const scopeThreadId = resolveScopeThreadId({
    threadType,
    rawThreadId: params.threadId,
    senderId: selfId,
    toId: params.threadId,
    selfId
  });
  const messageIds = extractResponseMessageIds(params.response);
  const baseRecord = {
    profile: params.profile,
    scopeThreadId,
    rawThreadId: params.threadId,
    threadType,
    peerId: threadType === "user" ? scopeThreadId : void 0,
    senderId: selfId,
    senderName: void 0,
    toId: threadType === "user" ? params.threadId : void 0,
    timestampMs: Date.now(),
    msgType: params.msgType,
    contentText: params.text,
    media: params.media,
    source: "send",
    rawPayloadJson: params.rawPayload ? JSON.stringify(params.rawPayload) : void 0,
    rawMessageJson: JSON.stringify(params.response)
  };
  if (messageIds.length === 0) {
    await persistMessage(baseRecord);
    return;
  }
  for (const msgId of messageIds) {
    await persistMessage({
      ...baseRecord,
      msgId
    });
  }
}
async function persistGroupMembersSnapshot(profile, groupId, api, groupInfo) {
  const rows = await listGroupMemberRows(api, groupId, groupInfo);
  const snapshotAtMs = Date.now();
  for (const row of rows) {
    await persistContact({
      profile,
      userId: row.userId,
      displayName: row.displayName,
      zaloName: row.zaloName,
      avatar: row.avatar,
      accountStatus: row.accountStatus,
      relationship: "seen_group",
      firstSeenAtMs: snapshotAtMs,
      lastSeenAtMs: snapshotAtMs,
      rawJson: row.rawJson
    });
  }
  await replaceThreadMembers(
    profile,
    groupId,
    rows.map((row) => ({
      profile,
      scopeThreadId: groupId,
      userId: row.userId,
      displayName: row.displayName,
      zaloName: row.zaloName,
      avatar: row.avatar,
      accountStatus: row.accountStatus,
      rawJson: row.rawJson ?? JSON.stringify(row),
      snapshotAtMs
    }))
  );
}
async function persistFriendDirectory(profile, api) {
  const friends = await api.getAllFriends();
  const nameById = /* @__PURE__ */ new Map();
  for (const friend2 of friends) {
    const record = friend2;
    const userId = normalizeCachedId(record.userId);
    if (!userId) continue;
    const displayName = typeof record.displayName === "string" && record.displayName.trim() ? record.displayName.trim() : void 0;
    const zaloName = typeof record.zaloName === "string" && record.zaloName.trim() ? record.zaloName.trim() : void 0;
    const avatar = typeof record.avatar === "string" && record.avatar.trim() ? record.avatar.trim() : void 0;
    const title = displayName || zaloName || userId;
    await persistContact({
      profile,
      userId,
      displayName,
      zaloName,
      avatar,
      accountStatus: typeof record.accountStatus === "number" && Number.isFinite(record.accountStatus) ? Math.trunc(record.accountStatus) : void 0,
      relationship: "friend",
      rawJson: JSON.stringify(friend2)
    });
    await persistThread({
      profile,
      scopeThreadId: userId,
      rawThreadId: userId,
      threadType: "user",
      peerId: userId,
      title,
      rawJson: JSON.stringify(friend2)
    });
    nameById.set(userId, title);
  }
  await reconcileFriendRelationships({
    profile,
    currentFriendIds: Array.from(nameById.keys())
  });
  return nameById;
}
function parseSinceDuration(label, value) {
  const parsed = parseDurationInput(value);
  if (parsed !== void 0) {
    return parsed;
  }
  if (!value || !value.trim()) {
    return void 0;
  }
  throw new Error(
    `${label} must be a relative duration like 30s, 7m, 24h, 7d, or 2w.`
  );
}
function parseTimeBoundary(label, value) {
  const parsed = parseTimeBoundaryInput(value);
  if (parsed !== void 0) {
    return parsed;
  }
  if (!value || !value.trim()) {
    return void 0;
  }
  throw new Error(
    `${label} must be an ISO timestamp, a date, or unix seconds/ms.`
  );
}
function pickExclusiveOption(primaryLabel, primaryValue, aliasLabel, aliasValue) {
  if (primaryValue?.trim() && aliasValue?.trim()) {
    throw new Error(`Use either ${primaryLabel} or ${aliasLabel}, not both.`);
  }
  return primaryValue?.trim() ? primaryValue : aliasValue?.trim() ? aliasValue : void 0;
}
function resolveMessageTimeRange(opts) {
  const sinceValue = opts.since?.trim() ? opts.since : void 0;
  const fromValue = opts.from?.trim() ? opts.from : void 0;
  const untilValue = pickExclusiveOption("--until", opts.until, "--to", opts.to);
  if (sinceValue && fromValue) {
    throw new Error("Use either --since for a rolling window or --from/--to for an explicit range, not both.");
  }
  if (sinceValue && untilValue) {
    throw new Error("Do not combine --since with --to/--until. Use --from/--to for explicit ranges.");
  }
  return {
    sinceMs: sinceValue ? parseSinceDuration("--since", sinceValue) : parseTimeBoundary("--from", fromValue),
    untilMs: parseTimeBoundary("--to/--until", untilValue)
  };
}
function resolveMessageQueryOptions(opts) {
  const { sinceMs, untilMs } = resolveMessageTimeRange(opts);
  if (opts.all && opts.limit?.trim()) {
    throw new Error("Use either --all or --limit, not both.");
  }
  const explicitLimit = parsePositiveIntOption("--limit", opts.limit);
  const hasTimeFilter = sinceMs !== void 0 || untilMs !== void 0;
  const limit = opts.all ? void 0 : explicitLimit ?? (hasTimeFilter ? void 0 : 20);
  const newestFirst = !Boolean(opts.oldestFirst);
  return {
    sinceMs,
    untilMs,
    limit,
    newestFirst
  };
}
async function resolveStoredChatThreadType(profile, chatId, forceGroup) {
  if (forceGroup) {
    return "group";
  }
  const row = await getThreadInfo({ profile, threadId: chatId });
  return row?.threadType === "group" ? "group" : "user";
}
async function confirmDestructiveAction(message) {
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
    throw new Error("Refusing destructive operation without --yes in non-interactive mode.");
  }
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout
  });
  try {
    const answer = (await rl.question(`${message} [y/N] `)).trim().toLowerCase();
    return answer === "y" || answer === "yes";
  } finally {
    rl.close();
  }
}
function createSyncProgressReporter() {
  if (!process.stderr.isTTY) {
    return () => {
    };
  }
  return (message) => {
    process.stderr.write(`[db sync] ${message}
`);
  };
}
function createDbSyncSummary(profile, dbPath, count) {
  return {
    profile,
    dbPath,
    windowCount: count,
    groupsSynced: 0,
    groupMessagesImported: 0,
    friendsSynced: 0,
    chatsSynced: 0,
    dmMessagesImported: 0,
    syncState: []
  };
}
function resolveSyncWindowCount(value) {
  return parsePositiveIntOption("--count", value) ?? 200;
}
async function collectConversationIds(api) {
  let pinnedIds = /* @__PURE__ */ new Set();
  let hiddenIds = /* @__PURE__ */ new Set();
  try {
    const pins = await api.getPinConversations();
    pinnedIds = new Set((pins.conversations ?? []).map((value) => String(value)));
  } catch {
  }
  try {
    const hidden = await api.getHiddenConversations();
    hiddenIds = new Set((hidden.threads ?? []).map((item) => String(item.thread_id)));
  } catch {
  }
  return { pinnedIds, hiddenIds };
}
async function prepareDbGroupTarget(params) {
  await persistThread({
    profile: params.profile,
    scopeThreadId: params.groupId,
    rawThreadId: params.groupId,
    threadType: "group",
    title: params.title,
    isPinned: params.pinnedIds.has(params.groupId),
    isHidden: params.hiddenIds.has(params.groupId),
    rawJson: params.rawJson
  });
  if (params.hydrateMembers === false) {
    return {};
  }
  try {
    await persistGroupMembersSnapshot(params.profile, params.groupId, params.api, params.group);
    return {};
  } catch (error) {
    return { memberSnapshotError: toErrorText2(error) };
  }
}
function resolveContactDisplayName(params) {
  return params.displayName?.trim() || params.zaloName?.trim() || params.fallbackTitle?.trim() || params.userId.trim() || void 0;
}
async function persistLiveDmContact(params) {
  if (!params.peerId) {
    return;
  }
  let displayName = params.senderDisplayName?.trim() || void 0;
  let zaloName = params.senderName?.trim() || void 0;
  let avatar;
  let accountStatus;
  let rawJson = params.rawJson;
  const existing = await getContactInfo({
    profile: params.profile,
    userId: params.peerId
  });
  if (!displayName || !existing?.avatar) {
    try {
      const response = await params.api.getUserInfo(params.peerId);
      const profiles = response.changed_profiles ?? {};
      const profile = profiles[params.peerId] ?? profiles[`${params.peerId}_0`] ?? Object.values(profiles).find((value) => normalizeCachedId(value?.userId ?? value?.uid) === params.peerId) ?? void 0;
      if (profile) {
        displayName = displayName || (typeof profile.displayName === "string" && profile.displayName.trim() ? profile.displayName.trim() : void 0) || (typeof profile.display_name === "string" && profile.display_name.trim() ? profile.display_name.trim() : void 0);
        zaloName = zaloName || (typeof profile.zaloName === "string" && profile.zaloName.trim() ? profile.zaloName.trim() : void 0) || (typeof profile.zalo_name === "string" && profile.zalo_name.trim() ? profile.zalo_name.trim() : void 0);
        avatar = typeof profile.avatar === "string" && profile.avatar.trim() ? profile.avatar.trim() : void 0;
        accountStatus = typeof profile.accountStatus === "number" && Number.isFinite(profile.accountStatus) ? Math.trunc(profile.accountStatus) : void 0;
        rawJson = JSON.stringify(profile);
      }
    } catch {
    }
  }
  const title = resolveContactDisplayName({
    userId: params.peerId,
    displayName,
    zaloName,
    fallbackTitle: typeof existing?.title === "string" ? existing.title : void 0
  });
  await persistContact({
    profile: params.profile,
    userId: params.peerId,
    displayName,
    zaloName,
    avatar,
    accountStatus,
    relationship: "seen_dm",
    firstSeenAtMs: params.timestampMs,
    lastSeenAtMs: params.timestampMs,
    rawJson
  });
  await persistThread({
    profile: params.profile,
    scopeThreadId: params.peerId,
    rawThreadId: params.peerId,
    threadType: "user",
    peerId: params.peerId,
    title,
    rawJson
  });
}
function extractGroupTitle(record) {
  if (!record) {
    return void 0;
  }
  return typeof record.name === "string" && record.name.trim() ? record.name.trim() : typeof record.groupName === "string" && record.groupName.trim() ? record.groupName.trim() : void 0;
}
async function findGroupDirectoryEntry(api, groupId) {
  const groups = await buildGroupsDetailed(api);
  return groups.find((item) => {
    const record = item;
    return normalizeCachedId(record.groupId ?? record.grid ?? record.threadId ?? record.id) === groupId;
  });
}
async function hydrateUnknownLiveGroup(params) {
  const existing = await getThreadInfo({
    profile: params.profile,
    threadId: params.groupId,
    threadType: "group"
  });
  if (existing && (existing.title || typeof existing.memberCount === "number" && existing.memberCount > 0)) {
    return;
  }
  let group2;
  let title = params.fallbackTitle?.trim() || void 0;
  try {
    const info = await params.api.getGroupInfo(params.groupId);
    group2 = info.gridInfoMap[params.groupId];
    title = extractGroupTitle(group2) ?? title;
  } catch {
  }
  if (!group2 || !title) {
    try {
      const directoryGroup = await findGroupDirectoryEntry(params.api, params.groupId);
      if (directoryGroup) {
        group2 = group2 ?? directoryGroup;
        title = extractGroupTitle(directoryGroup) ?? title;
      }
    } catch {
    }
  }
  if (group2 || title) {
    await prepareDbGroupTarget({
      profile: params.profile,
      api: params.api,
      groupId: params.groupId,
      group: group2,
      title,
      rawJson: group2 ? JSON.stringify(group2) : void 0,
      pinnedIds: /* @__PURE__ */ new Set(),
      hiddenIds: /* @__PURE__ */ new Set(),
      hydrateMembers: Boolean(group2)
    });
    return;
  }
  if (params.fallbackTitle?.trim()) {
    await persistThread({
      profile: params.profile,
      scopeThreadId: params.groupId,
      rawThreadId: params.groupId,
      threadType: "group",
      title: params.fallbackTitle.trim()
    });
  }
}
async function syncDbGroupHistoryFull(params) {
  if (params.targetGroupIds.size === 0) {
    return;
  }
  const getStoredGroupMessageCount = async () => {
    let total = 0;
    for (const groupId of params.targetGroupIds) {
      const row = await getThreadInfo({
        profile: params.profile,
        threadId: groupId,
        threadType: "group"
      });
      const count = row && typeof row.messageCount === "number" && Number.isFinite(row.messageCount) ? row.messageCount : 0;
      total += count;
    }
    return total;
  };
  const persistMessages = async (messages) => {
    for (const message of messages) {
      if (!params.targetGroupIds.has(message.threadId)) {
        continue;
      }
      processed += 1;
      await persistMessage(
        toDbRecordFromRecentMessage({
          profile: params.profile,
          message,
          source: "sync_group",
          selfId: params.selfId,
          title: params.titleById.get(message.threadId)
        })
      );
    }
  };
  const beforeCount = await getStoredGroupMessageCount();
  let processed = 0;
  let completeness = "complete";
  let stopReason = "exhausted";
  let pagesRequested = 0;
  let listenerImportedCount = 0;
  try {
    params.progress?.(`syncing full history for ${params.targetGroupIds.size} group(s)`);
    const result = await crawlGroupHistoryViaListener(params.api, {
      maxPages: Number.MAX_SAFE_INTEGER,
      idleTimeoutMs: 15e3,
      onMessages: persistMessages,
      onPage: ({ pagesRequested: pagesRequested2, filteredCount }) => {
        params.progress?.(
          `groups page ${pagesRequested2}: batch ${filteredCount}, processed ${processed}`
        );
      }
    });
    completeness = result.stopReason === "exhausted" ? "complete" : result.stopReason === "max_pages" || result.stopReason === "timeout" ? "partial" : "window";
    stopReason = result.stopReason;
    pagesRequested = result.pagesRequested;
    listenerImportedCount = await getStoredGroupMessageCount() - beforeCount;
  } catch (error) {
    stopReason = `fallback_window:${toErrorText2(error)}`;
    completeness = "window";
  }
  const fallbackCount = 200;
  params.progress?.(`merging recent group API window (${fallbackCount} per group)`);
  const beforeApiCount = await getStoredGroupMessageCount();
  const topoffErrors = [];
  for (const groupId of params.targetGroupIds) {
    try {
      const messages = await fetchRecentGroupMessagesViaApi(params.api, groupId, fallbackCount);
      await persistMessages(messages);
      params.progress?.(`group ${groupId}: fetched ${messages.length} message(s) from group history API`);
    } catch (error) {
      const message = toErrorText2(error);
      topoffErrors.push({ groupId, error: message });
      params.progress?.(`group ${groupId}: group history API skipped (${message})`);
    }
  }
  const afterCount = await getStoredGroupMessageCount();
  const apiAddedCount = afterCount - beforeApiCount;
  if (apiAddedCount > 0) {
    completeness = "window";
    if (stopReason === "exhausted" && listenerImportedCount === 0) {
      stopReason = "fallback_window:empty_listener_result";
    } else if (stopReason === "exhausted") {
      stopReason = "window_topoff:listener_incomplete";
    }
  }
  const imported = Math.max(afterCount - beforeCount, 0);
  for (const groupId of params.targetGroupIds) {
    await setSyncState({
      profile: params.profile,
      scopeThreadId: groupId,
      threadType: "group",
      status: "synced",
      completeness
    });
  }
  params.summary.groupsSynced += params.targetGroupIds.size;
  params.summary.groupMessagesImported += imported;
  params.summary.syncState.push({
    kind: "groups",
    groups: params.targetGroupIds.size,
    imported,
    completeness,
    stopReason,
    pagesRequested,
    topoffErrors
  });
}
async function syncDbFriendDirectory(params) {
  params.progress?.("syncing friend directory");
  const names = await persistFriendDirectory(params.profile, params.api);
  params.summary.friendsSynced += names.size;
  params.progress?.(`friend directory synced: ${names.size} friend(s)`);
  params.summary.syncState.push({
    kind: "friends",
    imported: names.size
  });
  return names;
}
async function syncDbChatThread(params) {
  const scopeThreadId = resolveScopeThreadId({
    threadType: "user",
    rawThreadId: params.threadId,
    senderId: params.selfId,
    toId: params.threadId,
    selfId: params.selfId
  });
  await persistThread({
    profile: params.profile,
    scopeThreadId,
    rawThreadId: params.threadId,
    threadType: "user",
    peerId: scopeThreadId,
    title: params.title,
    isPinned: params.pinnedIds.has(params.threadId) || params.pinnedIds.has(scopeThreadId),
    isHidden: params.hiddenIds.has(params.threadId) || params.hiddenIds.has(scopeThreadId)
  });
  const messages = await fetchRecentUserMessagesViaListener(params.api, params.threadId, params.count);
  for (const message of messages) {
    await persistMessage(
      toDbRecordFromRecentMessage({
        profile: params.profile,
        message,
        source: "sync_dm_best_effort",
        selfId: params.selfId,
        title: params.title
      })
    );
  }
  await setSyncState({
    profile: params.profile,
    scopeThreadId,
    threadType: "user",
    status: "synced",
    completeness: "best_effort"
  });
  params.summary.chatsSynced += 1;
  params.summary.dmMessagesImported += messages.length;
  params.progress?.(`chat ${scopeThreadId}: imported ${messages.length} message(s)`);
  params.summary.syncState.push({
    kind: "chat",
    chatId: scopeThreadId,
    rawThreadId: params.threadId,
    imported: messages.length,
    completeness: "best_effort"
  });
}
async function syncDbChatsBestEffort(params) {
  const scanLimit = Math.max(params.count * 10, 500);
  params.progress?.(`scanning recent DM/chat windows (target window ${params.count}, scan limit ${scanLimit})`);
  const messages = await fetchRecentUserMessagesAcrossThreads(params.api, scanLimit);
  const seenScopes = /* @__PURE__ */ new Set();
  for (const message of messages) {
    const title = params.titleById.get(message.threadId);
    const record = toDbRecordFromRecentMessage({
      profile: params.profile,
      message,
      source: "sync_dm_best_effort",
      selfId: params.selfId,
      title
    });
    await persistThread({
      profile: params.profile,
      scopeThreadId: record.scopeThreadId,
      rawThreadId: record.rawThreadId,
      threadType: "user",
      peerId: record.scopeThreadId,
      title,
      isPinned: params.pinnedIds.has(record.rawThreadId) || params.pinnedIds.has(record.scopeThreadId),
      isHidden: params.hiddenIds.has(record.rawThreadId) || params.hiddenIds.has(record.scopeThreadId)
    });
    await persistMessage(record);
    if (!seenScopes.has(record.scopeThreadId)) {
      seenScopes.add(record.scopeThreadId);
      await setSyncState({
        profile: params.profile,
        scopeThreadId: record.scopeThreadId,
        threadType: "user",
        status: "synced",
        completeness: "best_effort"
      });
    }
  }
  params.summary.chatsSynced += seenScopes.size;
  params.summary.dmMessagesImported += messages.length;
  params.progress?.(`chat scan finished: ${messages.length} message(s) across ${seenScopes.size} chat(s)`);
  params.summary.syncState.push({
    kind: "chats",
    imported: messages.length,
    chats: seenScopes.size,
    completeness: "best_effort"
  });
}
async function runDbSync(params) {
  const { profile, api } = await requireApi(params.command);
  try {
    const dbPath = await resolveDbPath(profile);
    params.progress?.(`starting sync for profile ${profile}`);
    const summary = createDbSyncSummary(
      profile,
      dbPath,
      params.mode === "all" || params.mode === "chats" || params.mode === "chat" ? params.count : void 0
    );
    const selfId = api.getOwnId();
    const selfInfo = normalizeMeInfoOutput(await api.fetchAccountInfo());
    await persistSelfProfile({
      profile,
      userId: selfId,
      displayName: typeof selfInfo.displayName === "string" && selfInfo.displayName.trim() ? selfInfo.displayName.trim() : void 0,
      infoJson: JSON.stringify(selfInfo)
    });
    const { pinnedIds, hiddenIds } = await collectConversationIds(api);
    let friendNames = /* @__PURE__ */ new Map();
    if (params.mode === "all" || params.mode === "friends" || params.mode === "chats") {
      friendNames = await syncDbFriendDirectory({
        profile,
        api,
        summary,
        progress: params.progress
      });
    }
    if (params.mode === "all" || params.mode === "groups") {
      const groups = await api.getAllGroups();
      const groupIds = Object.keys(groups.gridVerMap ?? {});
      const targetGroupIds = /* @__PURE__ */ new Set();
      const titleById = /* @__PURE__ */ new Map();
      params.progress?.(`syncing group directory for ${groupIds.length} group(s)`);
      for (const groupId of groupIds) {
        let group2;
        let title;
        try {
          try {
            group2 = await fetchGroupInfoRecord(api, groupId);
            title = extractGroupTitle(group2);
          } catch (error) {
            const message = toErrorText2(error);
            params.progress?.(`group ${groupId}: metadata unavailable (${message}), continuing`);
            summary.syncState.push({
              kind: "group",
              groupId,
              status: "warning",
              stage: "metadata",
              error: message
            });
          }
          const { memberSnapshotError } = await prepareDbGroupTarget({
            profile,
            api,
            groupId,
            group: group2,
            title,
            rawJson: group2 ? JSON.stringify(group2) : void 0,
            pinnedIds,
            hiddenIds,
            hydrateMembers: Boolean(group2)
          });
          if (memberSnapshotError) {
            params.progress?.(`group ${groupId}: member snapshot unavailable (${memberSnapshotError}), continuing`);
            summary.syncState.push({
              kind: "group",
              groupId,
              status: "warning",
              stage: "members",
              error: memberSnapshotError
            });
          }
          targetGroupIds.add(groupId);
          titleById.set(groupId, title);
        } catch (error) {
          const message = toErrorText2(error);
          params.progress?.(`group ${groupId}: skipped (${message})`);
          await setSyncState({
            profile,
            scopeThreadId: groupId,
            threadType: "group",
            status: "error",
            error: message
          });
          summary.syncState.push({
            kind: "group",
            groupId,
            status: "error",
            stage: "prepare",
            error: message
          });
        }
      }
      await syncDbGroupHistoryFull({
        profile,
        api,
        selfId,
        targetGroupIds,
        titleById,
        summary,
        progress: params.progress
      });
    }
    if (params.mode === "group") {
      if (!params.groupId) {
        throw new Error("Missing group id for db sync group.");
      }
      const group2 = await fetchGroupInfoRecord(api, params.groupId);
      const title = extractGroupTitle(group2);
      const { memberSnapshotError } = await prepareDbGroupTarget({
        profile,
        api,
        groupId: params.groupId,
        group: group2,
        title,
        rawJson: group2 ? JSON.stringify(group2) : void 0,
        pinnedIds,
        hiddenIds,
        hydrateMembers: Boolean(group2)
      });
      if (memberSnapshotError) {
        params.progress?.(`group ${params.groupId}: member snapshot unavailable (${memberSnapshotError}), continuing`);
        summary.syncState.push({
          kind: "group",
          groupId: params.groupId,
          status: "warning",
          stage: "members",
          error: memberSnapshotError
        });
      }
      await syncDbGroupHistoryFull({
        profile,
        api,
        selfId,
        targetGroupIds: /* @__PURE__ */ new Set([params.groupId]),
        titleById: /* @__PURE__ */ new Map([[params.groupId, title]]),
        summary,
        progress: params.progress
      });
    }
    if (params.mode === "chat") {
      if (!params.threadId) {
        throw new Error("Missing chat id for db sync chat.");
      }
      if (friendNames.size === 0) {
        friendNames = await persistFriendDirectory(profile, api);
      }
      await syncDbChatThread({
        profile,
        api,
        selfId,
        threadId: params.threadId,
        count: params.count,
        title: friendNames.get(params.threadId),
        pinnedIds,
        hiddenIds,
        summary,
        progress: params.progress
      });
    }
    if (params.mode === "all" || params.mode === "chats") {
      if (friendNames.size === 0) {
        friendNames = await persistFriendDirectory(profile, api);
      }
      await syncDbChatsBestEffort({
        profile,
        api,
        selfId,
        count: params.count,
        titleById: friendNames,
        pinnedIds,
        hiddenIds,
        summary,
        progress: params.progress
      });
    }
    params.progress?.(
      `done: groups=${summary.groupsSynced}, groupMessages=${summary.groupMessagesImported}, friends=${summary.friendsSynced}, chats=${summary.chatsSynced}, dmMessages=${summary.dmMessagesImported}`
    );
    return summary;
  } finally {
    await closeDb(profile);
  }
}
async function buildGroupsDetailed(api) {
  const groups = await api.getAllGroups();
  const ids = Object.keys(groups.gridVerMap ?? {});
  if (ids.length === 0) return [];
  const info = await fetchGroupInfoRecords(api, ids);
  return ids.map((id) => info.get(id)).filter((item) => Boolean(item));
}
function normalizeGroupMemberId(value) {
  if (typeof value === "number" && Number.isFinite(value)) {
    return String(Math.trunc(value));
  }
  if (typeof value !== "string") return "";
  const trimmed = value.trim();
  if (!trimmed) return "";
  return trimmed.replace(/_\d+$/, "");
}
async function listGroupMemberRows(api, groupId, preloadedGroupInfo) {
  const groupInfo = preloadedGroupInfo ?? await fetchGroupInfoRecord(api, groupId);
  if (!groupInfo) {
    throw new Error(`Group not found: ${groupId}`);
  }
  const idsFromMemberIds = Array.isArray(groupInfo.memberIds) ? groupInfo.memberIds.map((id) => normalizeGroupMemberId(id)).filter(Boolean) : [];
  const memVerList = groupInfo.memVerList;
  const idsFromMemVerList = Array.isArray(memVerList) ? memVerList.map((id) => normalizeGroupMemberId(id)).filter(Boolean) : [];
  const currentMems = Array.isArray(groupInfo.currentMems) ? groupInfo.currentMems : [];
  const currentMemberMap = /* @__PURE__ */ new Map();
  for (const member of currentMems) {
    const userId = normalizeGroupMemberId(member.id);
    if (!userId) continue;
    currentMemberMap.set(userId, {
      displayName: member.dName?.trim() || member.zaloName?.trim() || "",
      zaloName: member.zaloName?.trim() || ""
    });
  }
  const ids = Array.from(
    /* @__PURE__ */ new Set([
      ...idsFromMemberIds,
      ...idsFromMemVerList,
      ...Array.from(currentMemberMap.keys())
    ])
  );
  const profileLookup = ids.length > 0 ? await fetchGroupMemberProfiles(api, ids) : /* @__PURE__ */ new Map();
  const profileMap = /* @__PURE__ */ new Map();
  for (const [key, profile] of profileLookup.entries()) {
    if (!profile) continue;
    const normalizedKey = normalizeGroupMemberId(key);
    if (normalizedKey && !profileMap.has(normalizedKey)) {
      profileMap.set(normalizedKey, {
        ...profile,
        rawJson: JSON.stringify(profile)
      });
    }
    const profileId = normalizeGroupMemberId(profile.id);
    if (profileId && !profileMap.has(profileId)) {
      profileMap.set(profileId, {
        ...profile,
        rawJson: JSON.stringify(profile)
      });
    }
  }
  return ids.map((id) => ({
    userId: id,
    displayName: profileMap.get(id)?.displayName ?? currentMemberMap.get(id)?.displayName ?? "",
    zaloName: profileMap.get(id)?.zaloName ?? currentMemberMap.get(id)?.zaloName ?? "",
    avatar: profileMap.get(id)?.avatar,
    accountStatus: profileMap.get(id)?.accountStatus,
    rawJson: profileMap.get(id)?.rawJson
  }));
}
async function listGroupMentionMembers(api, threadId) {
  return await listGroupMemberRows(api, threadId);
}
async function refreshCacheForProfile(profile, api) {
  const [friends, groups] = await Promise.all([
    api.getAllFriends(),
    buildGroupsDetailed(api)
  ]);
  await writeCache(profile, {
    friends,
    groups,
    updatedAt: (/* @__PURE__ */ new Date()).toISOString()
  });
  return {
    friends: friends.length,
    groups: groups.length
  };
}
function parsePositiveIntFromEnv(name, fallback) {
  const raw = process.env[name]?.trim();
  if (!raw) return fallback;
  const parsed = Number.parseInt(raw, 10);
  if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
  return parsed;
}
function isListenerAlreadyStarted(error) {
  if (!(error instanceof Error)) return false;
  return /already started/i.test(error.message);
}
function toErrorText2(error) {
  return error instanceof Error ? error.message : String(error);
}
var SHUTDOWN_CALLBACKS = /* @__PURE__ */ new Set();
var shutdownSignalReceived = null;
var shutdownRunning = false;
function signalExitCode(signal) {
  if (signal === "SIGINT") return 130;
  if (signal === "SIGTERM") return 143;
  return 1;
}
function registerShutdownCallback(callback) {
  SHUTDOWN_CALLBACKS.add(callback);
  return () => {
    SHUTDOWN_CALLBACKS.delete(callback);
  };
}
async function runShutdownCallbacks(signal) {
  if (shutdownRunning) return;
  shutdownRunning = true;
  const callbacks = Array.from(SHUTDOWN_CALLBACKS);
  SHUTDOWN_CALLBACKS.clear();
  writeDebugLine(
    "process.signal",
    {
      signal,
      callbackCount: callbacks.length
    },
    void 0
  );
  for (const callback of callbacks) {
    try {
      await Promise.resolve(callback());
    } catch (error) {
      writeDebugLine(
        "process.signal.callback_error",
        {
          signal,
          message: toErrorText2(error)
        },
        void 0
      );
    }
  }
}
function installSignalHandler(signal) {
  process.on(signal, () => {
    if (shutdownSignalReceived) return;
    shutdownSignalReceived = signal;
    const exitCode = signalExitCode(signal);
    const forceExitMs = parsePositiveIntFromEnv("OPENZCA_SIGNAL_FORCE_EXIT_MS", 1500);
    const forceTimer = setTimeout(() => {
      process.exit(exitCode);
    }, forceExitMs);
    forceTimer.unref();
    void runShutdownCallbacks(signal).finally(() => {
      clearTimeout(forceTimer);
      process.exit(exitCode);
    });
  });
}
installSignalHandler("SIGINT");
installSignalHandler("SIGTERM");
async function withTimeout(task, timeoutMs, message) {
  let timeoutId;
  try {
    const timeout = new Promise((_, reject) => {
      timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
    });
    return await Promise.race([task, timeout]);
  } finally {
    if (timeoutId) clearTimeout(timeoutId);
  }
}
async function stopUploadListenerSafely(api, command, waitClosedMs = 1500) {
  await new Promise((resolve) => {
    let settled = false;
    let timeoutId;
    const finish = () => {
      if (settled) return;
      settled = true;
      if (timeoutId) clearTimeout(timeoutId);
      api.listener.off("closed", onClosed);
      resolve();
    };
    const onClosed = () => {
      finish();
    };
    api.listener.on("closed", onClosed);
    timeoutId = setTimeout(finish, waitClosedMs);
    try {
      const internalWs = api.listener.ws;
      if (internalWs && typeof internalWs.close === "function") {
        internalWs.close(1e3);
        writeDebugLine("msg.upload.listener.stop.ws_close", void 0, command);
      } else {
        api.listener.stop();
        writeDebugLine("msg.upload.listener.stop", void 0, command);
      }
    } catch {
      finish();
    }
  });
}
async function withUploadListener(api, command, task) {
  const connectTimeoutMs = parsePositiveIntFromEnv(
    "OPENZCA_UPLOAD_LISTENER_CONNECT_TIMEOUT_MS",
    8e3
  );
  const uploadTimeoutMs = parsePositiveIntFromEnv(
    "OPENZCA_UPLOAD_TIMEOUT_MS",
    12e4
  );
  let startedHere = false;
  const unregisterSignalCleanup = registerShutdownCallback(async () => {
    await stopUploadListenerSafely(api, command);
  });
  const sinkError = (error) => {
    writeDebugLine(
      "msg.upload.listener.error",
      {
        message: toErrorText2(error)
      },
      command
    );
  };
  const sinkClosed = (code, reason) => {
    writeDebugLine(
      "msg.upload.listener.closed",
      {
        code,
        reason: reason || void 0
      },
      command
    );
  };
  api.listener.on("error", sinkError);
  api.listener.on("closed", sinkClosed);
  try {
    await new Promise((resolve, reject) => {
      let settled = false;
      let timeoutId;
      const cleanup = () => {
        if (timeoutId) clearTimeout(timeoutId);
        api.listener.off("connected", onConnected);
        api.listener.off("error", onConnectError);
        api.listener.off("closed", onConnectClosed);
      };
      const finish = (error) => {
        if (settled) return;
        settled = true;
        cleanup();
        if (error) {
          reject(error);
          return;
        }
        resolve();
      };
      const onConnected = () => {
        writeDebugLine("msg.upload.listener.connected", void 0, command);
        finish();
      };
      const onConnectError = (error) => {
        finish(new Error(`Upload listener connection error: ${toErrorText2(error)}`));
      };
      const onConnectClosed = (code, reason) => {
        finish(
          new Error(
            `Upload listener closed before ready (code=${code}${reason ? `, reason=${reason}` : ""}).`
          )
        );
      };
      timeoutId = setTimeout(() => {
        finish(new Error(`Timed out waiting ${connectTimeoutMs}ms for upload listener connection.`));
      }, connectTimeoutMs);
      api.listener.on("connected", onConnected);
      api.listener.on("error", onConnectError);
      api.listener.on("closed", onConnectClosed);
      try {
        api.listener.start();
        startedHere = true;
        writeDebugLine(
          "msg.upload.listener.start",
          {
            connectTimeoutMs,
            uploadTimeoutMs
          },
          command
        );
      } catch (error) {
        if (isListenerAlreadyStarted(error)) {
          writeDebugLine("msg.upload.listener.already_started", void 0, command);
          finish();
          return;
        }
        finish(error);
      }
    });
    return await withTimeout(
      task(),
      uploadTimeoutMs,
      `Timed out waiting ${uploadTimeoutMs}ms for file upload completion.`
    );
  } finally {
    if (startedHere) {
      await stopUploadListenerSafely(api, command);
    }
    unregisterSignalCleanup();
    api.listener.off("error", sinkError);
    api.listener.off("closed", sinkClosed);
  }
}
function parseRecentMessageTs(value) {
  if (typeof value === "number" && Number.isFinite(value)) {
    return Math.trunc(value);
  }
  if (typeof value === "string") {
    const trimmed = value.trim();
    if (!trimmed) return 0;
    const parsed = Number(trimmed);
    if (Number.isFinite(parsed)) {
      return Math.trunc(parsed);
    }
    const parsedDate = Date.parse(trimmed);
    if (Number.isFinite(parsedDate)) {
      return Math.trunc(parsedDate);
    }
  }
  return 0;
}
function sortRecentMessagesNewestFirst(messages) {
  return [...messages].sort((left, right) => {
    const rightTs = parseRecentMessageTs(right.data?.ts);
    const leftTs = parseRecentMessageTs(left.data?.ts);
    if (rightTs !== leftTs) return rightTs - leftTs;
    const rightMsgId = String(right.data?.msgId ?? "");
    const leftMsgId = String(left.data?.msgId ?? "");
    if (rightMsgId !== leftMsgId) return rightMsgId.localeCompare(leftMsgId);
    const rightCliMsgId = String(right.data?.cliMsgId ?? "");
    const leftCliMsgId = String(left.data?.cliMsgId ?? "");
    return rightCliMsgId.localeCompare(leftCliMsgId);
  });
}
function getRecentMessageCursor(message) {
  if (!message) return "";
  const msgId = String(message.data?.msgId ?? "").trim();
  if (msgId) return msgId;
  const actionId = String(message.data?.actionId ?? "").trim();
  if (actionId) return actionId;
  return String(message.data?.cliMsgId ?? "").trim();
}
function getOldestRecentMessage(messages) {
  let oldest = null;
  for (const message of messages) {
    if (!oldest) {
      oldest = message;
      continue;
    }
    if (parseRecentMessageTs(message.data?.ts) < parseRecentMessageTs(oldest.data?.ts)) {
      oldest = message;
    }
  }
  return oldest;
}
function getRecentPageCursors(messages) {
  const cursors = [];
  const seen = /* @__PURE__ */ new Set();
  const addCursor = (value) => {
    const cursor = value.trim();
    if (!cursor || seen.has(cursor)) return;
    seen.add(cursor);
    cursors.push(cursor);
  };
  addCursor(getRecentMessageCursor(getOldestRecentMessage(messages)));
  addCursor(getRecentMessageCursor(messages[messages.length - 1] ?? null));
  addCursor(getRecentMessageCursor(messages[0] ?? null));
  return cursors;
}
function normalizeGroupHistoryMessages(messages, fallbackThreadId) {
  const normalized = [];
  for (const message of messages) {
    if (!message || typeof message !== "object") continue;
    const raw = message;
    if (raw.data && raw.threadId) {
      const wrapped = raw;
      normalized.push(wrapped);
      continue;
    }
    const threadIdRaw = String(raw.idTo ?? "").trim();
    normalized.push({
      threadId: threadIdRaw || fallbackThreadId,
      type: ThreadType3.Group,
      data: {
        actionId: typeof raw.actionId === "string" && raw.actionId.trim() ? raw.actionId : void 0,
        msgId: String(raw.msgId ?? ""),
        cliMsgId: String(raw.cliMsgId ?? ""),
        uidFrom: String(raw.uidFrom ?? ""),
        dName: typeof raw.dName === "string" ? raw.dName : void 0,
        ts: String(raw.ts ?? ""),
        msgType: String(raw.msgType ?? ""),
        content: raw.content ?? ""
      }
    });
  }
  return normalized;
}
async function fetchRecentGroupMessagesViaCustomApi(api, threadId, count) {
  const customApi = api;
  if (typeof customApi.__openzcaGroupHistory !== "function") {
    if (typeof customApi.custom !== "function") {
      throw new Error("Current zca-js build does not expose API custom hooks.");
    }
    customApi.custom("__openzcaGroupHistory", async ({ utils, props }) => {
      const serviceURL = utils.makeURL(`${api.zpwServiceMap.group[0]}/api/group/history`);
      const encryptedParams = utils.encodeAES(
        JSON.stringify({
          grid: props.groupId,
          count: props.count
        })
      );
      if (!encryptedParams) throw new Error("Failed to encrypt group history params.");
      const response2 = await utils.request(
        utils.makeURL(serviceURL, { params: encryptedParams }),
        { method: "GET" }
      );
      return await utils.resolve(response2, (result) => {
        if (typeof result.data === "string") {
          try {
            return JSON.parse(result.data);
          } catch {
            return { groupMsgs: [] };
          }
        }
        return result.data ?? { groupMsgs: [] };
      });
    });
  }
  const response = await customApi.__openzcaGroupHistory?.({
    groupId: threadId,
    count
  });
  const messagesRaw = Array.isArray(response?.groupMsgs) ? response.groupMsgs : [];
  return sortRecentMessagesNewestFirst(
    normalizeGroupHistoryMessages(messagesRaw, threadId)
  ).slice(0, count);
}
async function fetchRecentGroupMessagesViaApi(api, threadId, count) {
  const historyApi = api.getGroupChatHistory;
  if (typeof historyApi === "function") {
    try {
      const response = await historyApi(threadId, count);
      const messagesRaw = Array.isArray(response?.groupMsgs) ? response.groupMsgs : [];
      return sortRecentMessagesNewestFirst(
        normalizeGroupHistoryMessages(messagesRaw, threadId)
      ).slice(0, count);
    } catch {
    }
  }
  try {
    return await fetchRecentGroupMessagesViaCustomApi(api, threadId, count);
  } catch {
  }
  return fetchRecentGroupMessagesViaListener(api, threadId, count);
}
async function fetchRecentGroupMessagesViaListener(api, threadId, count) {
  const result = await crawlGroupHistoryViaListener(api, {
    threadId,
    limit: count,
    maxPages: parsePositiveIntFromEnv("OPENZCA_RECENT_GROUP_MAX_PAGES", 20),
    idleTimeoutMs: 12e3
  });
  return result.messages;
}
async function crawlGroupHistoryViaListener(api, options) {
  return new Promise((resolve, reject) => {
    let settled = false;
    let stopReason = "closed";
    const shouldCollect = options.limit != null || !options.onMessages;
    const collected = [];
    const seenMessageKeys = /* @__PURE__ */ new Set();
    const requestedCursors = /* @__PURE__ */ new Set();
    let pagesRequested = 0;
    let idleTimer;
    let processing = Promise.resolve();
    const toKey = (message) => {
      const msgId = String(message.data?.msgId ?? "");
      const cliMsgId = String(message.data?.cliMsgId ?? "");
      return `${message.threadId}:${msgId}:${cliMsgId}`;
    };
    const requestPage = (lastId) => {
      const cursor = String(lastId ?? "").trim();
      if (cursor) {
        if (requestedCursors.has(cursor)) return false;
        requestedCursors.add(cursor);
      }
      pagesRequested += 1;
      api.listener.requestOldMessages(ThreadType3.Group, cursor || null);
      return true;
    };
    const armIdleTimer = () => {
      if (idleTimer) {
        clearTimeout(idleTimer);
      }
      idleTimer = setTimeout(() => {
        finish(void 0, "timeout");
      }, options.idleTimeoutMs);
    };
    const cleanup = () => {
      if (idleTimer) {
        clearTimeout(idleTimer);
      }
      api.listener.off("connected", onConnected);
      api.listener.off("old_messages", onOldMessages);
      api.listener.off("error", onError);
      api.listener.off("closed", onClosed);
      try {
        api.listener.stop();
      } catch {
      }
    };
    const finish = (error, reason) => {
      if (settled) return;
      settled = true;
      if (reason) {
        stopReason = reason;
      }
      void processing.then(() => {
        cleanup();
        if (error) {
          reject(error);
          return;
        }
        resolve({
          messages: options.limit != null ? sortRecentMessagesNewestFirst(collected).slice(0, options.limit) : collected,
          stopReason,
          pagesRequested
        });
      }).catch((processingError) => {
        cleanup();
        reject(processingError);
      });
    };
    const onConnected = () => {
      try {
        armIdleTimer();
        requestPage(null);
      } catch (error) {
        finish(error, "closed");
      }
    };
    const onOldMessages = (messages, type) => {
      if (type !== ThreadType3.Group) return;
      armIdleTimer();
      const typedMessages = messages;
      processing = processing.then(async () => {
        const filtered = [];
        for (const message of typedMessages) {
          if (options.threadId && message.threadId !== options.threadId) {
            continue;
          }
          const key = toKey(message);
          if (seenMessageKeys.has(key)) continue;
          seenMessageKeys.add(key);
          if (shouldCollect) {
            collected.push(message);
          }
          filtered.push(message);
        }
        if (filtered.length > 0) {
          await options.onMessages?.(filtered);
        }
        await options.onPage?.({
          pagesRequested,
          filteredCount: filtered.length,
          collectedCount: collected.length
        });
        if (options.limit != null && collected.length >= options.limit) {
          finish(void 0, "limit");
          return;
        }
        if (typedMessages.length === 0) {
          finish(void 0, "exhausted");
          return;
        }
        if (pagesRequested >= options.maxPages) {
          finish(void 0, "max_pages");
          return;
        }
        const cursorCandidates = getRecentPageCursors(typedMessages);
        let requested = false;
        for (const cursor of cursorCandidates) {
          if (requestPage(cursor)) {
            requested = true;
            break;
          }
        }
        if (!requested) {
          finish(void 0, "exhausted");
        }
      }).catch((error) => {
        finish(error, "closed");
      });
    };
    const onError = (error) => {
      finish(error, "closed");
    };
    const onClosed = () => {
      finish(void 0, "closed");
    };
    api.listener.on("connected", onConnected);
    api.listener.on("old_messages", onOldMessages);
    api.listener.on("error", onError);
    api.listener.on("closed", onClosed);
    try {
      api.listener.start();
    } catch (error) {
      finish(error);
    }
  });
}
async function fetchRecentUserMessagesViaListener(api, threadId, count) {
  return new Promise((resolve, reject) => {
    let settled = false;
    const collected = [];
    const seenMessageKeys = /* @__PURE__ */ new Set();
    const requestedCursors = /* @__PURE__ */ new Set();
    const maxPages = parsePositiveIntFromEnv("OPENZCA_RECENT_USER_MAX_PAGES", 20);
    let pagesRequested = 0;
    const toKey = (message) => {
      const msgId = String(message.data?.msgId ?? "");
      const cliMsgId = String(message.data?.cliMsgId ?? "");
      return `${msgId}:${cliMsgId}`;
    };
    const requestPage = (lastId) => {
      const cursor = String(lastId ?? "").trim();
      if (cursor) {
        if (requestedCursors.has(cursor)) return false;
        requestedCursors.add(cursor);
      }
      pagesRequested += 1;
      api.listener.requestOldMessages(ThreadType3.User, cursor || null);
      return true;
    };
    const cleanup = () => {
      clearTimeout(timeoutId);
      api.listener.off("connected", onConnected);
      api.listener.off("old_messages", onOldMessages);
      api.listener.off("error", onError);
      api.listener.off("closed", onClosed);
      try {
        api.listener.stop();
      } catch {
      }
    };
    const finish = (error) => {
      if (settled) return;
      settled = true;
      cleanup();
      if (error) {
        reject(error);
        return;
      }
      resolve(sortRecentMessagesNewestFirst(collected).slice(0, count));
    };
    const onConnected = () => {
      try {
        requestPage(null);
      } catch (error) {
        finish(error);
      }
    };
    const onOldMessages = (messages, type) => {
      if (type !== ThreadType3.User) return;
      const typedMessages = messages;
      for (const message of typedMessages) {
        if (message.threadId === threadId) {
          const key = toKey(message);
          if (seenMessageKeys.has(key)) continue;
          seenMessageKeys.add(key);
          collected.push(message);
        }
      }
      if (collected.length >= count) {
        finish();
        return;
      }
      if (typedMessages.length === 0) {
        finish();
        return;
      }
      if (pagesRequested >= maxPages) {
        finish();
        return;
      }
      try {
        const cursorCandidates = getRecentPageCursors(typedMessages);
        let requested = false;
        for (const cursor of cursorCandidates) {
          if (requestPage(cursor)) {
            requested = true;
            break;
          }
        }
        if (!requested) finish();
      } catch (error) {
        finish(error);
      }
    };
    const onError = (error) => {
      finish(error);
    };
    const onClosed = () => {
      finish();
    };
    const timeoutId = setTimeout(() => {
      finish();
    }, 12e3);
    api.listener.on("connected", onConnected);
    api.listener.on("old_messages", onOldMessages);
    api.listener.on("error", onError);
    api.listener.on("closed", onClosed);
    try {
      api.listener.start();
    } catch (error) {
      finish(error);
    }
  });
}
async function fetchRecentUserMessagesAcrossThreads(api, maxMessages) {
  return new Promise((resolve, reject) => {
    let settled = false;
    const collected = [];
    const seenMessageKeys = /* @__PURE__ */ new Set();
    const requestedCursors = /* @__PURE__ */ new Set();
    const maxPages = parsePositiveIntFromEnv("OPENZCA_RECENT_USER_MAX_PAGES", 20);
    let pagesRequested = 0;
    const toKey = (message) => {
      const msgId = String(message.data?.msgId ?? "");
      const cliMsgId = String(message.data?.cliMsgId ?? "");
      return `${message.threadId}:${msgId}:${cliMsgId}`;
    };
    const requestPage = (lastId) => {
      const cursor = String(lastId ?? "").trim();
      if (cursor) {
        if (requestedCursors.has(cursor)) return false;
        requestedCursors.add(cursor);
      }
      pagesRequested += 1;
      api.listener.requestOldMessages(ThreadType3.User, cursor || null);
      return true;
    };
    const cleanup = () => {
      clearTimeout(timeoutId);
      api.listener.off("connected", onConnected);
      api.listener.off("old_messages", onOldMessages);
      api.listener.off("error", onError);
      api.listener.off("closed", onClosed);
      try {
        api.listener.stop();
      } catch {
      }
    };
    const finish = (error) => {
      if (settled) return;
      settled = true;
      cleanup();
      if (error) {
        reject(error);
        return;
      }
      resolve(sortRecentMessagesNewestFirst(collected).slice(0, maxMessages));
    };
    const onConnected = () => {
      try {
        requestPage(null);
      } catch (error) {
        finish(error);
      }
    };
    const onOldMessages = (messages, type) => {
      if (type !== ThreadType3.User) return;
      const typedMessages = messages;
      for (const message of typedMessages) {
        const key = toKey(message);
        if (seenMessageKeys.has(key)) continue;
        seenMessageKeys.add(key);
        collected.push(message);
      }
      if (collected.length >= maxMessages || typedMessages.length === 0 || pagesRequested >= maxPages) {
        finish();
        return;
      }
      try {
        const cursorCandidates = getRecentPageCursors(typedMessages);
        let requested = false;
        for (const cursor of cursorCandidates) {
          if (requestPage(cursor)) {
            requested = true;
            break;
          }
        }
        if (!requested) finish();
      } catch (error) {
        finish(error);
      }
    };
    const onError = (error) => {
      finish(error);
    };
    const onClosed = () => {
      finish();
    };
    const timeoutId = setTimeout(() => {
      finish();
    }, 12e3);
    api.listener.on("connected", onConnected);
    api.listener.on("old_messages", onOldMessages);
    api.listener.on("error", onError);
    api.listener.on("closed", onClosed);
    try {
      api.listener.start();
    } catch (error) {
      finish(error);
    }
  });
}
function normalizeRecentMessageMentions(value) {
  if (!Array.isArray(value)) {
    return [];
  }
  const rows = [];
  const parseOptionalMentionInt = (input) => {
    if (typeof input === "number" && Number.isFinite(input)) {
      return Math.trunc(input);
    }
    if (typeof input === "string" && input.trim()) {
      const parsed = Number(input.trim());
      if (Number.isFinite(parsed)) {
        return Math.trunc(parsed);
      }
    }
    return void 0;
  };
  for (const item of value) {
    if (!item || typeof item !== "object") continue;
    const record = item;
    const uid = normalizeCachedId(record.uid);
    if (!uid) continue;
    rows.push({
      uid,
      pos: parseOptionalMentionInt(record.pos),
      len: parseOptionalMentionInt(record.len),
      type: typeof record.type === "number" && Number.isFinite(record.type) ? Math.trunc(record.type) : typeof record.type === "string" && record.type.trim() ? Number.parseInt(record.type.trim(), 10) : void 0,
      rawJson: JSON.stringify(record)
    });
  }
  return rows;
}
function toDbRecordFromRecentMessage(params) {
  const content = params.message.data?.content;
  const quote = params.message.data?.quote;
  return normalizeInboundListenRecord({
    profile: params.profile,
    threadType: params.message.type === ThreadType3.Group ? "group" : "user",
    rawThreadId: params.message.threadId,
    senderId: params.message.data?.uidFrom,
    senderName: params.message.data?.dName,
    toId: params.message.data?.idTo,
    selfId: params.selfId,
    title: params.title,
    msgId: params.message.data?.msgId,
    cliMsgId: params.message.data?.cliMsgId,
    actionId: params.message.data?.actionId,
    timestampMs: toEpochMs(params.message.data?.ts),
    msgType: params.message.data?.msgType,
    contentText: typeof content === "string" ? content : void 0,
    contentJson: content && typeof content === "object" ? JSON.stringify(content) : void 0,
    quoteMsgId: quote?.globalMsgId != null ? String(quote.globalMsgId) : void 0,
    quoteCliMsgId: quote?.cliMsgId != null ? String(quote.cliMsgId) : void 0,
    quoteOwnerId: quote?.ownerId != null ? String(quote.ownerId) : void 0,
    quoteText: typeof quote?.msg === "string" ? quote.msg : void 0,
    mentions: normalizeRecentMessageMentions(
      params.message.data?.mentions
    ),
    rawMessage: params.message.data,
    source: params.source
  });
}
async function parseCredentialFile(filePath) {
  const raw = await fs7.readFile(filePath, "utf8");
  const parsed = JSON.parse(raw);
  if (!parsed.imei || !parsed.cookie || !parsed.userAgent) {
    throw new Error("Credential file must include imei, cookie, and userAgent.");
  }
  return {
    imei: parsed.imei,
    cookie: parsed.cookie,
    userAgent: parsed.userAgent,
    language: parsed.language
  };
}
function sleep3(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}
async function waitForFileContent(filePath, timeoutMs) {
  const startedAt = Date.now();
  while (Date.now() - startedAt < timeoutMs) {
    try {
      const data = await fs7.readFile(filePath);
      if (data.length > 0) {
        return data;
      }
    } catch {
    }
    await sleep3(150);
  }
  throw new Error(`Timed out waiting for QR image file: ${filePath}`);
}
async function emitQrBase64FromDetachedLogin(profile, qrPath) {
  const scriptPath = process.argv[1];
  if (!scriptPath) {
    throw new Error("Cannot resolve CLI entrypoint for QR base64 mode.");
  }
  const tempDir = await fs7.mkdtemp(path7.join(os5.tmpdir(), "openzca-qr-"));
  const targetPath = path7.resolve(qrPath ?? path7.join(tempDir, "qr.png"));
  const child = spawn2(
    process.execPath,
    [scriptPath, "--profile", profile, "auth", "login", "--qr-path", targetPath],
    {
      detached: true,
      stdio: "ignore",
      env: {
        ...process.env,
        OPENZCA_QR_RENDER: "ascii",
        OPENZCA_QR_AUTO_OPEN: "0"
      }
    }
  );
  child.unref();
  const png = await waitForFileContent(targetPath, 2e4);
  console.log(`data:image/png;base64,${png.toString("base64")}`);
}
function asObject(value) {
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    return null;
  }
  return value;
}
function getStringCandidate(record, keys) {
  for (const key of keys) {
    const value = record[key];
    if (typeof value === "string" && value.trim()) {
      return value.trim();
    }
    if (typeof value === "number" && Number.isFinite(value)) {
      return String(value);
    }
  }
  return "";
}
function normalizeMessageType(value) {
  if (typeof value !== "string") return "";
  return value.trim().toLowerCase();
}
function looksLikeStructuredJsonString2(value) {
  const trimmed = value.trim();
  if (trimmed.length < 2) return false;
  const first = trimmed[0];
  const last = trimmed[trimmed.length - 1];
  if (first === "{" && last === "}") return true;
  if (first === "[" && last === "]") return true;
  return false;
}
function normalizeStructuredContent(value, depth = 0) {
  if (depth > 5 || value === null || value === void 0) {
    return value;
  }
  if (typeof value === "string") {
    const trimmed = value.trim();
    if (!looksLikeStructuredJsonString2(trimmed)) {
      return value;
    }
    try {
      const parsed = JSON.parse(trimmed);
      return normalizeStructuredContent(parsed, depth + 1);
    } catch {
      return value;
    }
  }
  if (Array.isArray(value)) {
    return value.map((entry) => normalizeStructuredContent(entry, depth + 1));
  }
  const record = asObject(value);
  if (!record) {
    return value;
  }
  const normalized = {};
  for (const [key, nested] of Object.entries(record)) {
    normalized[key] = normalizeStructuredContent(nested, depth + 1);
  }
  return normalized;
}
function detectInboundMediaKind(msgType, content) {
  const normalizedType = normalizeMessageType(msgType);
  if (normalizedType.includes("photo") || normalizedType.includes("gif") || normalizedType.includes("sticker")) {
    return "image";
  }
  if (normalizedType.includes("video")) return "video";
  if (normalizedType.includes("voice") || normalizedType.includes("audio")) return "audio";
  if (normalizedType.includes("share.file")) return "file";
  if (normalizedType.includes("link") || normalizedType.includes("location")) return null;
  const record = asObject(content);
  if (!record) return null;
  if (getStringCandidate(record, ["voiceUrl", "m4aUrl", "audioUrl", "voice_url", "m4a_url", "audio_url"])) {
    return "audio";
  }
  if (getStringCandidate(record, ["videoUrl"])) return "video";
  if (getStringCandidate(record, [
    "hdUrl",
    "normalUrl",
    "thumbUrl",
    "thumb",
    "rawUrl",
    "oriUrl",
    "imageUrl"
  ])) {
    return "image";
  }
  if (getStringCandidate(record, ["fileUrl", "fileName", "fileId", "href", "url"])) return "file";
  return null;
}
function collectHttpUrls(value, sink, depth = 0) {
  if (depth > 5 || sink.size >= 16) return;
  if (typeof value === "string") {
    const escapedNormalized = value.replace(/\\\//g, "/");
    const matches = escapedNormalized.match(/https?:\/\/[^\s"'<>`]+/gi) ?? [];
    for (const match of matches) {
      const cleaned = match.replace(/[)\],.;"'`]+$/g, "").trim();
      if (isHttpUrl(cleaned)) {
        sink.add(cleaned);
      }
      if (sink.size >= 16) {
        return;
      }
    }
    return;
  }
  if (Array.isArray(value)) {
    for (const item of value) {
      collectHttpUrls(item, sink, depth + 1);
      if (sink.size >= 16) return;
    }
    return;
  }
  const record = asObject(value);
  if (!record) return;
  for (const nested of Object.values(record)) {
    collectHttpUrls(nested, sink, depth + 1);
    if (sink.size >= 16) return;
  }
}
function preferredMediaKeys(kind) {
  switch (kind) {
    case "image":
      return [
        "hdUrl",
        "normalUrl",
        "rawUrl",
        "oriUrl",
        "imageUrl",
        "photoUrl",
        "fileUrl",
        "thumbUrl",
        "thumb",
        "href",
        "url",
        "src"
      ];
    case "video":
      return [
        "videoUrl",
        "video_url",
        "mediaUrl",
        "streamUrl",
        "playUrl",
        "fileUrl",
        "rawUrl",
        "href",
        "url",
        "src"
      ];
    case "audio":
      return [
        "voiceUrl",
        "m4aUrl",
        "audioUrl",
        "voice_url",
        "m4a_url",
        "audio_url",
        "mediaUrl",
        "downloadUrl",
        "streamUrl",
        "playUrl",
        "fileUrl",
        "rawUrl",
        "href",
        "url",
        "src"
      ];
    case "file":
      return [
        "fileUrl",
        "downloadUrl",
        "rawUrl",
        "normalUrl",
        "oriUrl",
        "fileLink",
        "href",
        "url",
        "src"
      ];
  }
}
function resolvePreferredMediaUrls(kind, content) {
  const ordered = [];
  const seen = /* @__PURE__ */ new Set();
  const push = (url) => {
    if (!seen.has(url)) {
      seen.add(url);
      ordered.push(url);
    }
  };
  const record = asObject(content);
  if (record) {
    for (const key of preferredMediaKeys(kind)) {
      if (!(key in record)) continue;
      const urls = /* @__PURE__ */ new Set();
      collectHttpUrls(record[key], urls);
      for (const url of urls) {
        push(url);
      }
    }
  }
  const collected = /* @__PURE__ */ new Set();
  collectHttpUrls(content, collected);
  for (const url of collected) {
    push(url);
  }
  return ordered;
}
function mediaExtFromTypeOrUrl(mediaType, mediaUrl, kind) {
  const normalizedType = mediaType?.split(";")[0]?.trim().toLowerCase() ?? "";
  const byType = {
    "image/jpeg": ".jpg",
    "image/jpg": ".jpg",
    "image/png": ".png",
    "image/webp": ".webp",
    "image/gif": ".gif",
    "image/heic": ".heic",
    "image/heif": ".heif",
    "video/mp4": ".mp4",
    "video/quicktime": ".mov",
    "video/webm": ".webm",
    "audio/mpeg": ".mp3",
    "audio/mp3": ".mp3",
    "audio/aac": ".aac",
    "audio/x-aac": ".aac",
    "audio/mp4": ".m4a",
    "audio/x-m4a": ".m4a",
    "audio/wav": ".wav",
    "audio/ogg": ".ogg",
    "audio/webm": ".webm",
    "application/pdf": ".pdf",
    "text/plain": ".txt",
    "text/markdown": ".md",
    "text/csv": ".csv",
    "text/tab-separated-values": ".tsv",
    "application/json": ".json",
    "application/xml": ".xml",
    "text/xml": ".xml",
    "application/zip": ".zip",
    "application/gzip": ".gz",
    "application/x-tar": ".tar",
    "application/x-7z-compressed": ".7z",
    "application/vnd.rar": ".rar",
    "application/vnd.ms-excel": ".xls",
    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
    "application/vnd.ms-excel.sheet.binary.macroenabled.12": ".xlsb",
    "application/vnd.ms-excel.sheet.macroenabled.12": ".xlsm",
    "application/msword": ".doc",
    "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
    "application/vnd.ms-powerpoint": ".ppt",
    "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
    "application/rtf": ".rtf",
    "application/vnd.oasis.opendocument.text": ".odt",
    "application/vnd.oasis.opendocument.spreadsheet": ".ods",
    "application/vnd.oasis.opendocument.presentation": ".odp"
  };
  const fromType = byType[normalizedType];
  if (fromType) return fromType;
  try {
    const parsedUrl = new URL(mediaUrl);
    const ext = path7.extname(parsedUrl.pathname);
    if (ext) return ext;
  } catch {
  }
  if (kind === "video") return ".mp4";
  if (kind === "audio") return ".m4a";
  if (kind === "image") return ".jpg";
  return ".bin";
}
function parseMaxInboundMediaBytes() {
  const raw = process.env.OPENZCA_LISTEN_MEDIA_MAX_BYTES?.trim();
  if (!raw) return 20 * 1024 * 1024;
  const parsed = Number(raw);
  if (!Number.isFinite(parsed) || parsed <= 0) return 20 * 1024 * 1024;
  return Math.trunc(parsed);
}
function parseMaxInboundMediaFiles() {
  const raw = process.env.OPENZCA_LISTEN_MEDIA_MAX_FILES?.trim();
  if (!raw) return 4;
  const parsed = Number(raw);
  if (!Number.isFinite(parsed) || parsed <= 0) return 4;
  return Math.min(Math.max(Math.trunc(parsed), 1), 16);
}
function parseInboundMediaFetchTimeoutMs() {
  const raw = process.env.OPENZCA_LISTEN_MEDIA_FETCH_TIMEOUT_MS?.trim();
  if (!raw) return 1e4;
  const parsed = Number(raw);
  if (!Number.isFinite(parsed) || parsed < 0) return 1e4;
  return Math.trunc(parsed);
}
function resolveOpenClawMediaDir() {
  const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim() || path7.join(os5.homedir(), ".openclaw");
  return path7.join(stateDir, "media");
}
function resolveInboundMediaDir(profile) {
  const configuredRaw = process.env.OPENZCA_LISTEN_MEDIA_DIR?.trim();
  if (configuredRaw) {
    const configured = normalizeMediaInput(configuredRaw);
    return path7.isAbsolute(configured) ? configured : path7.resolve(process.cwd(), configured);
  }
  const legacyRequested = process.env.OPENZCA_LISTEN_MEDIA_LEGACY_DIR?.trim() === "1";
  if (legacyRequested) {
    return path7.join(getProfileDir(profile), "inbound-media");
  }
  return path7.join(resolveOpenClawMediaDir(), "openzca", profile, "inbound");
}
async function cacheInboundMediaToProfile(profile, mediaUrl, kind) {
  const maxBytes = parseMaxInboundMediaBytes();
  const timeoutMs = parseInboundMediaFetchTimeoutMs();
  const controller = timeoutMs > 0 ? new AbortController() : void 0;
  const timeoutId = controller && timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null;
  let response;
  try {
    response = await fetch(mediaUrl, controller ? { signal: controller.signal } : void 0);
  } catch (error) {
    if (error instanceof Error && error.name === "AbortError") {
      throw new Error(`Timed out downloading inbound media: ${mediaUrl} (${timeoutMs}ms)`);
    }
    throw error;
  } finally {
    if (timeoutId) {
      clearTimeout(timeoutId);
    }
  }
  if (!response.ok) {
    throw new Error(`Failed to download inbound media: ${mediaUrl} (${response.status})`);
  }
  const mediaType = response.headers.get("content-type");
  const contentLengthRaw = response.headers.get("content-length");
  const contentLength = contentLengthRaw ? Number(contentLengthRaw) : Number.NaN;
  if (Number.isFinite(contentLength) && contentLength > maxBytes) {
    return null;
  }
  const data = Buffer.from(await response.arrayBuffer());
  if (data.length === 0 || data.length > maxBytes) {
    return null;
  }
  const dir = resolveInboundMediaDir(profile);
  await fs7.mkdir(dir, { recursive: true });
  const ext = mediaExtFromTypeOrUrl(mediaType, mediaUrl, kind);
  const id = `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
  const mediaPath = path7.join(dir, `${id}${ext}`);
  await fs7.writeFile(mediaPath, data);
  return { mediaPath, mediaType };
}
async function cacheRemoteMediaEntries(params) {
  if (params.urls.length === 0) return [];
  return Promise.all(
    params.urls.map(async (mediaUrl) => {
      let mediaPath;
      let mediaType = null;
      try {
        const cached = await cacheInboundMediaToProfile(params.profile, mediaUrl, params.kind);
        if (cached) {
          mediaPath = cached.mediaPath;
          mediaType = cached.mediaType;
        }
      } catch (error) {
        console.error(
          `Warning: failed to cache ${params.warningLabel} (${error instanceof Error ? error.message : String(error)})`
        );
        writeDebugLine(
          params.debugErrorEvent,
          {
            profile: params.profile,
            [params.debugUrlKey]: mediaUrl,
            message: error instanceof Error ? error.message : String(error)
          },
          params.command
        );
      }
      return {
        mediaPath,
        mediaUrl,
        mediaType: mediaType ?? void 0
      };
    })
  );
}
function summarizeStructuredContent(msgType, content) {
  const normalizedType = normalizeMessageType(msgType);
  const record = asObject(content);
  if (normalizedType.includes("link") && record) {
    const href = getStringCandidate(record, ["href", "url", "src"]);
    if (href) return href;
  }
  if (record) {
    const candidateText = getStringCandidate(record, [
      "msg",
      "message",
      "text",
      "caption",
      "title",
      "description",
      "fileName",
      "name",
      "href",
      "url",
      "src"
    ]);
    if (candidateText) return candidateText;
  }
  return normalizedType ? `<non-text:${normalizedType}>` : "<non-text-message>";
}
function buildMediaAttachedText(params) {
  const entries = params.mediaEntries.map((entry) => ({
    pathOrUrl: entry.mediaPath ?? entry.mediaUrl,
    mediaPath: entry.mediaPath,
    mediaUrl: entry.mediaUrl,
    mediaType: entry.mediaType
  })).filter((entry) => Boolean(entry.pathOrUrl));
  if (entries.length === 0) {
    return params.caption?.trim() || "";
  }
  const fallbackType = params.fallbackKind === "image" ? "image/*" : params.fallbackKind === "video" ? "video/*" : params.fallbackKind === "audio" ? "audio/*" : void 0;
  const multiple = entries.length > 1;
  const lines = [];
  if (multiple) {
    lines.push(`[media attached: ${entries.length} files]`);
  }
  for (let index = 0; index < entries.length; index += 1) {
    const entry = entries[index];
    const type = entry.mediaType?.trim() || fallbackType;
    const typePart = type ? ` (${type})` : "";
    const urlPart = entry.mediaPath && entry.mediaUrl ? ` | ${entry.mediaUrl}` : "";
    const prefix = multiple ? `[media attached ${index + 1}/${entries.length}: ` : "[media attached: ";
    lines.push(`${prefix}${entry.pathOrUrl}${typePart}${urlPart}]`);
  }
  const mediaNote = lines.join("\n");
  if (params.caption?.trim()) {
    return `${mediaNote}
${params.caption.trim()}`;
  }
  return mediaNote;
}
function parseToggleDefaultTrue(value) {
  if (value === void 0) return true;
  const normalized = value.trim().toLowerCase();
  if (!normalized) return true;
  if (["0", "false", "no", "off"].includes(normalized)) return false;
  return true;
}
function truncatePreview(value, maxLength = 220) {
  const normalized = value.trim();
  if (normalized.length <= maxLength) return normalized;
  return `${normalized.slice(0, Math.max(maxLength - 3, 1))}...`;
}
function normalizeQuoteContext(value) {
  const normalized = normalizeStructuredContent(value);
  const record = asObject(normalized);
  if (!record) return null;
  const ownerId = getStringCandidate(record, [
    "ownerId",
    "uidFrom",
    "fromId",
    "senderId",
    "uid"
  ]);
  const senderName = getStringCandidate(record, [
    "fromD",
    "senderName",
    "dName",
    "displayName",
    "name"
  ]);
  const msg2 = getStringCandidate(record, [
    "msg",
    "message",
    "text",
    "content",
    "title",
    "description"
  ]);
  const cliMsgId = getStringCandidate(record, ["cliMsgId"]);
  const globalMsgId = getStringCandidate(record, ["globalMsgId", "msgId", "realMsgId"]);
  const cliMsgType = typeof record.cliMsgType === "number" && Number.isFinite(record.cliMsgType) ? Math.trunc(record.cliMsgType) : void 0;
  const attach = record.attach === void 0 ? void 0 : normalizeStructuredContent(record.attach);
  const mediaUrlSet = /* @__PURE__ */ new Set();
  if (attach !== void 0) {
    collectHttpUrls(attach, mediaUrlSet);
  }
  const tsRaw = record.ts;
  const tsNumeric = typeof tsRaw === "number" ? tsRaw : typeof tsRaw === "string" ? Number(tsRaw) : Number.NaN;
  const ts = Number.isFinite(tsNumeric) ? Math.trunc(tsNumeric) : void 0;
  if (!ownerId && !senderName && !msg2 && !cliMsgId && !globalMsgId && attach === void 0) {
    return null;
  }
  return {
    ownerId: ownerId || void 0,
    senderName: senderName || void 0,
    msg: msg2 || void 0,
    attach,
    mediaUrls: mediaUrlSet.size > 0 ? [...mediaUrlSet] : void 0,
    ts,
    cliMsgId: cliMsgId || void 0,
    globalMsgId: globalMsgId || void 0,
    cliMsgType
  };
}
function buildReplyContextText(quote) {
  const from = quote.senderName || quote.ownerId || "unknown";
  const messageText = quote.msg?.trim() || "";
  const attachText = quote.attach !== void 0 ? summarizeStructuredContent("quote", quote.attach) : "";
  let summary = messageText || attachText;
  if (!summary || summary === "<non-text:quote>" || summary === "<non-text-message>") {
    if (quote.mediaUrls && quote.mediaUrls.length > 0) {
      summary = quote.mediaUrls.length > 1 ? `${quote.mediaUrls[0]} (+${quote.mediaUrls.length - 1} more)` : quote.mediaUrls[0];
    } else {
      summary = "<quoted-message>";
    }
  }
  return `[reply context: ${from}: ${truncatePreview(summary.replace(/\s+/g, " "))}]`;
}
function buildReplyMediaAttachedText(params) {
  const entries = params.mediaEntries.map((entry) => ({
    pathOrUrl: entry.mediaPath ?? entry.mediaUrl,
    mediaPath: entry.mediaPath,
    mediaUrl: entry.mediaUrl,
    mediaType: entry.mediaType
  })).filter((entry) => Boolean(entry.pathOrUrl));
  if (entries.length === 0) return "";
  const multiple = entries.length > 1;
  const lines = [];
  if (multiple) {
    lines.push(`[reply media attached: ${entries.length} files]`);
  }
  for (let index = 0; index < entries.length; index += 1) {
    const entry = entries[index];
    const typePart = entry.mediaType?.trim() ? ` (${entry.mediaType.trim()})` : "";
    const urlPart = entry.mediaPath && entry.mediaUrl ? ` | ${entry.mediaUrl}` : "";
    const prefix = multiple ? `[reply media attached ${index + 1}/${entries.length}: ` : "[reply media attached: ";
    lines.push(`${prefix}${entry.pathOrUrl}${typePart}${urlPart}]`);
  }
  return lines.join("\n");
}
function parseOptionalInt(value) {
  const numeric = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
  if (!Number.isFinite(numeric)) return void 0;
  return Math.trunc(numeric);
}
function buildInboundMention(record, rawText) {
  const uid = getStringCandidate(record, ["uid", "userId", "user_id", "id"]);
  if (!uid) return null;
  const pos = parseOptionalInt(record.pos ?? record.offset ?? record.start ?? record.index);
  const len = parseOptionalInt(record.len ?? record.length);
  const type = parseOptionalInt(record.type ?? record.kind);
  let text = getStringCandidate(record, ["text", "label", "name"]) || (typeof pos === "number" && typeof len === "number" && len > 0 && pos >= 0 && pos < rawText.length ? rawText.slice(pos, Math.min(rawText.length, pos + len)) : "");
  if (!text.trim()) {
    text = "";
  }
  return {
    uid,
    ...typeof pos === "number" ? { pos } : {},
    ...typeof len === "number" ? { len } : {},
    ...typeof type === "number" ? { type } : {},
    ...text ? { text } : {}
  };
}
function collectInboundMentions(value, sink, rawText, depth = 0) {
  if (depth > 6 || sink.size >= 64 || value === null || value === void 0) {
    return;
  }
  if (typeof value === "string") {
    if (!looksLikeStructuredJsonString2(value)) return;
    try {
      const parsed = JSON.parse(value);
      collectInboundMentions(parsed, sink, rawText, depth + 1);
    } catch {
    }
    return;
  }
  if (Array.isArray(value)) {
    for (const item of value) {
      const mention = asObject(item) ? buildInboundMention(item, rawText) : null;
      if (mention) {
        const key = `${mention.uid}|${mention.pos ?? ""}|${mention.len ?? ""}|${mention.type ?? ""}`;
        sink.set(key, mention);
        continue;
      }
      collectInboundMentions(item, sink, rawText, depth + 1);
      if (sink.size >= 64) return;
    }
    return;
  }
  const record = asObject(value);
  if (!record) return;
  const direct = buildInboundMention(record, rawText);
  if (direct) {
    const key = `${direct.uid}|${direct.pos ?? ""}|${direct.len ?? ""}|${direct.type ?? ""}`;
    sink.set(key, direct);
  }
  const mentionKeys = [
    "mentions",
    "mentionInfo",
    "mention_info",
    "mentionList",
    "mention_list",
    "mention"
  ];
  for (const key of mentionKeys) {
    if (!(key in record)) continue;
    collectInboundMentions(record[key], sink, rawText, depth + 1);
    if (sink.size >= 64) return;
  }
  for (const nested of Object.values(record)) {
    collectInboundMentions(nested, sink, rawText, depth + 1);
    if (sink.size >= 64) return;
  }
}
function extractInboundMentions(params) {
  const sink = /* @__PURE__ */ new Map();
  const candidates = [
    params.messageData.mentions,
    params.messageData.mentionInfo,
    params.messageData.mention_info,
    params.messageData.mentionList,
    params.messageData.mention_list,
    params.messageData.mention,
    params.messageData.content,
    params.parsedContent
  ];
  for (const candidate of candidates) {
    collectInboundMentions(candidate, sink, params.rawText);
    if (sink.size >= 64) break;
  }
  return [...sink.values()];
}
function normalizeFriendLookupRows(value) {
  const queue = [value];
  const rows = [];
  const seen = /* @__PURE__ */ new Set();
  while (queue.length > 0) {
    const current = queue.shift();
    if (!current) {
      continue;
    }
    if (Array.isArray(current)) {
      queue.push(...current);
      continue;
    }
    const record = asObject(current);
    if (!record) {
      continue;
    }
    for (const nestedKey of [
      "data",
      "user",
      "users",
      "items",
      "results",
      "profiles",
      "friends",
      "friend",
      "profile",
      "info"
    ]) {
      if (record[nestedKey] !== void 0 && record[nestedKey] !== null) {
        queue.push(record[nestedKey]);
      }
    }
    const userId = getStringCandidate(record, [
      "userId",
      "uid",
      "user_id",
      "userKey",
      "id"
    ]);
    const displayName = getStringCandidate(record, [
      "displayName",
      "zaloName",
      "name",
      "username"
    ]);
    const avatar = getStringCandidate(record, [
      "avatar",
      "avatarUrl",
      "avatar_url",
      "thumbSrc",
      "thumb"
    ]);
    if (!userId && !displayName) {
      continue;
    }
    const normalized = {
      ...record
    };
    if (userId) normalized.userId = userId;
    if (displayName) normalized.displayName = displayName;
    if (avatar) normalized.avatar = avatar;
    const dedupeKey = userId || `${displayName}|${avatar}`;
    if (seen.has(dedupeKey)) {
      continue;
    }
    seen.add(dedupeKey);
    rows.push(normalized);
  }
  return rows;
}
function toEpochSeconds(input) {
  const numeric = typeof input === "number" ? input : typeof input === "string" ? Number(input) : Number.NaN;
  if (!Number.isFinite(numeric) || numeric <= 0) {
    return Math.floor(Date.now() / 1e3);
  }
  if (numeric > 1e10) {
    return Math.floor(numeric / 1e3);
  }
  return Math.floor(numeric);
}
function toEpochMs(input) {
  const numeric = typeof input === "number" ? input : typeof input === "string" ? Number(input) : Number.NaN;
  if (!Number.isFinite(numeric) || numeric <= 0) {
    return Date.now();
  }
  if (numeric < 1e10) {
    return Math.floor(numeric * 1e3);
  }
  return Math.floor(numeric);
}
function parseNonNegativeIntOption(label, value) {
  if (!value || !value.trim()) return void 0;
  const parsed = Number(value.trim());
  if (!Number.isFinite(parsed) || parsed < 0) {
    throw new Error(`${label} must be a non-negative number.`);
  }
  return Math.trunc(parsed);
}
function parsePositiveIntOption(label, value) {
  if (!value || !value.trim()) return void 0;
  const parsed = Number(value.trim());
  if (!Number.isFinite(parsed) || parsed <= 0) {
    throw new Error(`${label} must be a positive number.`);
  }
  return Math.trunc(parsed);
}
program.name("openzca").description("Open-source zca-cli compatible wrapper powered by zca-js").version(PKG_VERSION).option("-p, --profile <name>", "Profile name").option("--debug", "Enable debug logging").option("--debug-file <path>", "Debug log file path").showHelpAfterError();
program.hook("preAction", (_parent, actionCommand) => {
  if (!resolveDebugEnabled(actionCommand)) {
    return;
  }
  DEBUG_COMMAND_START.set(actionCommand, Date.now());
  writeDebugLine(
    "command.start",
    {
      command: commandPathLabel(actionCommand),
      argv: process.argv.slice(2),
      cwd: process.cwd(),
      profileFlag: getDebugOptions(actionCommand).profile ?? null,
      envProfile: process.env.OPENZCA_PROFILE ?? process.env.ZCA_PROFILE ?? null
    },
    actionCommand
  );
});
program.hook("postAction", (_parent, actionCommand) => {
  if (!resolveDebugEnabled(actionCommand)) {
    return;
  }
  const startedAt = DEBUG_COMMAND_START.get(actionCommand);
  writeDebugLine(
    "command.done",
    {
      command: commandPathLabel(actionCommand),
      durationMs: typeof startedAt === "number" ? Date.now() - startedAt : void 0
    },
    actionCommand
  );
});
var account = program.command("account").description("Multi-account profile management");
account.command("list").alias("ls").alias("l").description("List all account profiles").action(
  wrapAction(async () => {
    const db = await listProfiles();
    const active = await resolveProfileName();
    const rows = await Promise.all(
      Object.entries(db.profiles).map(async ([name, meta]) => ({
        name,
        label: meta.label ?? "",
        default: name === db.defaultProfile,
        active: name === active,
        loggedIn: Boolean(await loadCredentials(name)),
        createdAt: meta.createdAt,
        updatedAt: meta.updatedAt
      }))
    );
    output(rows, false);
  })
);
account.command("current").alias("whoami").description("Show current active profile").action(
  wrapAction(async (command) => {
    const profile = await currentProfile(command);
    console.log(profile);
  })
);
account.command("switch <name>").alias("use").description("Set default profile").action(
  wrapAction(async (name) => {
    await setDefaultProfile(name);
    console.log(`Default profile set to: ${name}`);
  })
);
account.command("add [name]").alias("new").description("Create a new profile").action(
  wrapAction(async (name = "default") => {
    await addProfile(name);
    console.log(`Profile created: ${name}`);
    console.log(`Next step: openzca --profile ${name} auth login`);
  })
);
account.command("label <name> <label>").description("Set label for profile").action(
  wrapAction(async (name, label) => {
    await setProfileLabel(name, label);
    console.log(`Updated label for ${name}`);
  })
);
account.command("remove <name>").alias("rm").description("Remove profile").action(
  wrapAction(async (name) => {
    await removeProfile(name);
    console.log(`Removed profile: ${name}`);
  })
);
var auth = program.command("auth").description("Authentication and local cache");
auth.command("login").description("Login with QR code").option("-q, --qr-path <path>", "Save QR image path").option(
  "--open-qr",
  "Open QR image in default viewer (or set OPENZCA_QR_OPEN=1)"
).option(
  "--qr-base64",
  "Output QR code as data URL and return immediately (integration mode)"
).action(
  wrapAction(
    async (opts, command) => {
      const profile = await profileForLogin();
      if (opts.qrBase64) {
        await emitQrBase64FromDetachedLogin(profile, opts.qrPath);
        return;
      }
      const { api } = await loginWithQrAndPersist(profile, opts.qrPath, {
        openQr: opts.openQr
      });
      const me2 = normalizeAccountInfo(await api.fetchAccountInfo());
      console.log(`Logged in profile ${profile} as ${me2.displayName} (${me2.userId})`);
      try {
        const cache = await refreshCacheForProfile(profile, api);
        console.log(
          `Cache refreshed: ${cache.friends} friends, ${cache.groups} groups`
        );
      } catch (error) {
        console.error(
          `Warning: login succeeded but cache refresh failed (${error instanceof Error ? error.message : String(error)})`
        );
      }
    }
  )
);
auth.command("login-cred [file]").alias("login-creds").description("Login using credential JSON file").action(
  wrapAction(async (file, command) => {
    const profile = await currentProfile(command);
    const credentials = file ? await parseCredentialFile(path7.resolve(normalizeMediaInput(file))) : toCredentials(
      await loadCredentials(profile) ?? (() => {
        throw new Error(
          `No saved credentials for profile "${profile}". Run: openzca auth login`
        );
      })()
    );
    const api = await loginWithCredentialPayload(profile, credentials);
    const me2 = normalizeAccountInfo(await api.fetchAccountInfo());
    console.log(`Logged in profile ${profile} as ${me2.displayName} (${me2.userId})`);
  })
);
auth.command("logout").description("Remove saved credentials from active profile").action(
  wrapAction(async (command) => {
    const profile = await currentProfile(command);
    await clearCredentials(profile);
    console.log(`Logged out profile ${profile}`);
  })
);
auth.command("status").description("Show login status").action(
  wrapAction(async (command) => {
    const profile = await currentProfile(command);
    const credentials = await loadCredentials(profile);
    if (!credentials) {
      throw new Error(`Profile ${profile}: not logged in`);
    }
    const api = await createZaloClient().login(toCredentials(credentials));
    const me2 = normalizeAccountInfo(await api.fetchAccountInfo());
    output(
      {
        profile,
        loggedIn: true,
        userId: me2.userId,
        displayName: me2.displayName,
        credentialsPath: getCredentialsPath(profile)
      },
      false
    );
  })
);
auth.command("cache-refresh").description("Refresh friends/groups cache").action(
  wrapAction(async (command) => {
    const { profile, api } = await requireApi(command);
    const counts = await refreshCacheForProfile(profile, api);
    console.log(
      `Cache refreshed for ${profile}: ${counts.friends} friends, ${counts.groups} groups`
    );
  })
);
auth.command("cache-info").description("Show cache metadata").action(
  wrapAction(async (command) => {
    const profile = await currentProfile(command);
    const cache = await readCache(profile);
    output(
      {
        profile,
        appHome: APP_HOME,
        profilesFile: PROFILES_FILE,
        friendsCount: cache.friends.length,
        groupsCount: cache.groups.length,
        updatedAt: cache.updatedAt
      },
      false
    );
  })
);
auth.command("cache-clear").description("Clear local cache").action(
  wrapAction(async (command) => {
    const profile = await currentProfile(command);
    await clearCache(profile);
    console.log(`Cache cleared for profile ${profile}`);
  })
);
var dbCmd = program.command("db").description("Profile-scoped SQLite message database");
dbCmd.command("enable").option("--path <path>", "Custom SQLite file path").description("Enable local SQLite persistence for the active profile").action(
  wrapAction(async (opts, command) => {
    const profile = await currentProfile(command);
    await enableDb(profile, opts.path);
    output(await getDbStatus(profile), false);
  })
);
dbCmd.command("disable").description("Disable automatic SQLite persistence for the active profile").action(
  wrapAction(async (command) => {
    const profile = await currentProfile(command);
    await disableDb(profile);
    await closeDb(profile);
    output(await getDbStatus(profile), false);
  })
);
dbCmd.command("reset").option("-y, --yes", "Delete the SQLite DB file for the active profile").option("--drop-config", "Also remove the DB config file").option("-j, --json", "JSON output").description("Delete the local SQLite DB for the active profile").action(
  wrapAction(async (opts, command) => {
    if (!opts.yes) {
      const confirmed = await confirmDestructiveAction(
        "Reset the local SQLite DB for the active profile?"
      );
      if (!confirmed) {
        console.log("Cancelled.");
        return;
      }
    }
    const profile = await currentProfile(command);
    const dbPath = await resolveDbPath(profile);
    const configPath = getDbConfigPath(profile);
    await closeDb(profile);
    const removedPaths = [];
    const deleteIfExists = async (filename) => {
      try {
        await fs7.unlink(filename);
        removedPaths.push(filename);
      } catch (error) {
        if (error.code !== "ENOENT") {
          throw error;
        }
      }
    };
    await deleteIfExists(dbPath);
    await deleteIfExists(`${dbPath}-wal`);
    await deleteIfExists(`${dbPath}-shm`);
    if (opts.dropConfig) {
      await deleteIfExists(configPath);
    }
    const status = await getDbStatus(profile);
    output(
      {
        profile,
        removedPaths,
        droppedConfig: Boolean(opts.dropConfig),
        status: {
          enabled: status.enabled,
          path: status.path,
          exists: status.exists,
          messageCount: status.messageCount,
          threadCount: status.threadCount,
          groupCount: status.groupCount,
          userCount: status.userCount
        }
      },
      Boolean(opts.json)
    );
  })
);
dbCmd.command("status").option("-j, --json", "JSON output").description("Show DB status for the active profile").action(
  wrapAction(async (opts, command) => {
    const profile = await currentProfile(command);
    const config = await readDbConfig(profile);
    const status = await getDbStatus(profile);
    const syncRows = await listSyncState({ profile });
    output(
      {
        profile,
        enabled: status.enabled,
        path: await resolveDbPath(profile),
        exists: status.exists,
        configuredPath: config.path ?? null,
        messageCount: status.messageCount,
        threadCount: status.threadCount,
        groupCount: status.groupCount,
        userCount: status.userCount,
        syncStates: {
          total: syncRows.length,
          synced: syncRows.filter((row) => row.status === "synced").length,
          errors: syncRows.filter((row) => row.status === "error").length
        },
        lastMessageAtMs: status.lastMessageAtMs ?? null,
        updatedAt: status.updatedAt ?? null
      },
      Boolean(opts.json)
    );
  })
);
var dbMe = dbCmd.command("me").description("Query stored self profile data");
dbMe.command("info").option("-j, --json", "JSON output").description("Show stored self profile info").action(
  wrapAction(async (opts, command) => {
    const profile = await currentProfile(command);
    const row = await getSelfProfile(profile);
    if (!row?.info) {
      throw new Error("No stored self profile in DB. Run `openzca db sync` first.");
    }
    output(row.info, Boolean(opts.json));
  })
);
dbMe.command("id").description("Show stored self user ID").action(
  wrapAction(async (command) => {
    const profile = await currentProfile(command);
    const row = await getSelfProfile(profile);
    if (!row?.userId) {
      throw new Error("No stored self profile in DB. Run `openzca db sync` first.");
    }
    console.log(row.userId);
  })
);
var dbGroup = dbCmd.command("group").description("Query stored group data");
dbGroup.command("list").option("-j, --json", "JSON output").description("List groups stored in the local DB").action(
  wrapAction(async (opts, command) => {
    const profile = await currentProfile(command);
    output(await listGroups(profile), Boolean(opts.json));
  })
);
dbGroup.command("info <groupId>").option("-j, --json", "JSON output").description("Show stored info for a group").action(
  wrapAction(async (groupId, opts, command) => {
    const profile = await currentProfile(command);
    const row = await getThreadInfo({ profile, threadId: groupId, threadType: "group" });
    if (!row) {
      throw new Error(`Group not found in DB: ${groupId}`);
    }
    output(row, Boolean(opts.json));
  })
);
dbGroup.command("members <groupId>").option("-j, --json", "JSON output").description("List stored members for a group").action(
  wrapAction(async (groupId, opts, command) => {
    const profile = await currentProfile(command);
    output(await listThreadMembers({ profile, threadId: groupId }), Boolean(opts.json));
  })
);
dbGroup.command("messages <groupId>").option("--since <duration>", "Rolling window ending now: duration like 30s, 7m, 24h, 7d, or 2w").option("--from <time>", "Lower time bound: ISO timestamp, date, or unix seconds/ms").option("--until <time>", "Upper time bound: ISO timestamp, date, or unix seconds/ms").option("--to <time>", "Alias for --until").option("--limit <count>", "Maximum number of rows").option("--all", "Return all matching rows").option("--oldest-first", "Sort oldest-first instead of newest-first").option("-j, --json", "JSON output").description("List stored messages for a group").action(
  wrapAction(async (groupId, opts, command) => {
    const profile = await currentProfile(command);
    const { sinceMs, untilMs, limit, newestFirst } = resolveMessageQueryOptions(opts);
    const rows = await listMessages({
      profile,
      threadId: groupId,
      threadType: "group",
      sinceMs,
      untilMs,
      limit,
      newestFirst
    });
    output(
      {
        groupId,
        count: rows.length,
        messages: rows
      },
      Boolean(opts.json)
    );
  })
);
function registerDbContactQueryCommand(params) {
  params.command.command("list").option("-j, --json", "JSON output").description(`List ${params.label} stored in the local DB`).action(
    wrapAction(async (opts, command) => {
      const profile = await currentProfile(command);
      const rows = params.relationship === "friend" ? await listFriends(profile) : await listContacts({ profile });
      output(rows, Boolean(opts.json));
    })
  );
  params.command.command("find <query>").option("-j, --json", "JSON output").description(`Find stored ${params.label} by ID or name`).action(
    wrapAction(async (query, opts, command) => {
      const profile = await currentProfile(command);
      const rows = params.relationship === "friend" ? await findFriends({ profile, query }) : await findContacts({ profile, query });
      output(rows, Boolean(opts.json));
    })
  );
  params.command.command("info <userId>").option("-j, --json", "JSON output").description(`Show stored info for a ${params.label.slice(0, -1)}`).action(
    wrapAction(async (userId, opts, command) => {
      const profile = await currentProfile(command);
      const row = params.relationship === "friend" ? await getFriendInfo({ profile, userId }) : await getContactInfo({ profile, userId });
      if (!row) {
        throw new Error(`${params.label.slice(0, -1).replace(/^./, (value) => value.toUpperCase())} not found in DB: ${userId}`);
      }
      output(row, Boolean(opts.json));
    })
  );
  params.command.command("messages <userId>").option("--since <duration>", "Rolling window ending now: duration like 30s, 7m, 24h, 7d, or 2w").option("--from <time>", "Lower time bound: ISO timestamp, date, or unix seconds/ms").option("--until <time>", "Upper time bound: ISO timestamp, date, or unix seconds/ms").option("--to <time>", "Alias for --until").option("--limit <count>", "Maximum number of rows").option("--all", "Return all matching rows").option("--oldest-first", "Sort oldest-first instead of newest-first").option("-j, --json", "JSON output").description(`List stored direct-message rows for a ${params.label.slice(0, -1)}`).action(
    wrapAction(async (userId, opts, command) => {
      const profile = await currentProfile(command);
      const { sinceMs, untilMs, limit, newestFirst } = resolveMessageQueryOptions(opts);
      const contact = params.relationship === "friend" ? await getFriendInfo({ profile, userId }) : await getContactInfo({ profile, userId });
      const threadId = contact && typeof contact.chatId === "string" && contact.chatId.trim() ? contact.chatId : userId;
      const rows = await listMessages({
        profile,
        threadId,
        threadType: "user",
        sinceMs,
        untilMs,
        limit,
        newestFirst
      });
      output(
        {
          userId,
          chatId: threadId,
          count: rows.length,
          messages: rows
        },
        Boolean(opts.json)
      );
    })
  );
}
var dbContact = dbCmd.command("contact").description("Query stored contact data");
registerDbContactQueryCommand({
  command: dbContact,
  label: "contacts"
});
var dbFriend = dbCmd.command("friend").description("Query stored confirmed friend contacts");
registerDbContactQueryCommand({
  command: dbFriend,
  label: "friends",
  relationship: "friend"
});
var dbChat = dbCmd.command("chat").description("Query stored conversation data");
dbChat.command("list").option("-j, --json", "JSON output").description("List chats stored in the local DB").action(
  wrapAction(async (opts, command) => {
    const profile = await currentProfile(command);
    output(await listChats(profile), shouldOutputJson(opts));
  })
);
dbChat.command("info <chatId>").option("-g, --group", "Read as a group chat").option("-j, --json", "JSON output").description("Show stored info for a chat").action(
  wrapAction(async (chatId, opts, command) => {
    const profile = await currentProfile(command);
    const row = await getThreadInfo({
      profile,
      threadId: chatId,
      threadType: opts.group ? "group" : void 0
    });
    if (!row) {
      throw new Error(`Chat not found in DB: ${chatId}`);
    }
    output(row, shouldOutputJson(opts));
  })
);
dbChat.command("messages <chatId>").option("-g, --group", "Read as a group chat").option("--since <duration>", "Rolling window ending now: duration like 30s, 7m, 24h, 7d, or 2w").option("--from <time>", "Lower time bound: ISO timestamp, date, or unix seconds/ms").option("--until <time>", "Upper time bound: ISO timestamp, date, or unix seconds/ms").option("--to <time>", "Alias for --until").option("--limit <count>", "Maximum number of rows").option("--all", "Return all matching rows").option("--oldest-first", "Sort oldest-first instead of newest-first").option("-j, --json", "JSON output").description("List stored messages for a chat").action(
  wrapAction(async (chatId, opts, command) => {
    const profile = await currentProfile(command);
    const threadType = await resolveStoredChatThreadType(profile, chatId, opts.group);
    const { sinceMs, untilMs, limit, newestFirst } = resolveMessageQueryOptions(opts);
    const rows = await listMessages({
      profile,
      threadId: chatId,
      threadType,
      sinceMs,
      untilMs,
      limit,
      newestFirst
    });
    output(
      {
        chatId,
        threadType,
        count: rows.length,
        messages: rows
      },
      shouldOutputJson(opts)
    );
  })
);
var dbMessage = dbCmd.command("message").description("Query stored messages");
dbMessage.command("get <id>").option("-j, --json", "JSON output").description("Read one stored message by msgId, cliMsgId, or internal uid").action(
  wrapAction(async (id, opts, command) => {
    const profile = await currentProfile(command);
    const row = await getMessageById({ profile, id });
    if (!row) {
      throw new Error(`Message not found in DB: ${id}`);
    }
    output(row, Boolean(opts.json));
  })
);
var dbSync = dbCmd.command("sync").description("Sync discoverable data into the local DB");
dbSync.enablePositionalOptions();
dbSync.option("-n, --count <count>", "Recent DM/chat messages to fetch per window", "200").option("-j, --json", "JSON output").action(
  wrapAction(async (opts, command) => {
    const count = resolveSyncWindowCount(opts.count);
    const progress = createSyncProgressReporter();
    const summary = await runDbSync({
      command,
      mode: "all",
      count,
      progress
    });
    output(summary, Boolean(opts.json));
  })
);
dbSync.command("all").option("-n, --count <count>", "Recent DM/chat messages to fetch per window", "200").option("-j, --json", "JSON output").description("Sync full group history, friend directory, and recent DM/chat windows").action(
  wrapAction(async (_opts, command) => {
    const count = resolveSyncWindowCount(readCliOptionValue(["--count", "-n"]));
    output(
      await runDbSync({ command, mode: "all", count, progress: createSyncProgressReporter() }),
      readCliFlag(["--json", "-j"])
    );
  })
);
dbSync.command("groups").option("-j, --json", "JSON output").description("Sync group directory, members, and full group history").action(
  wrapAction(async (_opts, command) => {
    output(
      await runDbSync({ command, mode: "groups", count: 0, progress: createSyncProgressReporter() }),
      readCliFlag(["--json", "-j"])
    );
  })
);
dbSync.command("friends").option("-j, --json", "JSON output").description("Sync friend directory only").action(
  wrapAction(async (_opts, command) => {
    output(
      await runDbSync({ command, mode: "friends", count: 0, progress: createSyncProgressReporter() }),
      readCliFlag(["--json", "-j"])
    );
  })
);
dbSync.command("chats").option("-n, --count <count>", "Recent messages to fetch per scan/window", "200").option("-j, --json", "JSON output").description("Sync discoverable chat windows (DM/chat sync is best-effort)").action(
  wrapAction(async (_opts, command) => {
    const count = resolveSyncWindowCount(readCliOptionValue(["--count", "-n"]));
    output(
      await runDbSync({ command, mode: "chats", count, progress: createSyncProgressReporter() }),
      readCliFlag(["--json", "-j"])
    );
  })
);
dbSync.command("group <groupId>").option("-j, --json", "JSON output").description("Sync one group with full group history").action(
  wrapAction(async (groupId, _opts, command) => {
    output(
      await runDbSync({
        command,
        mode: "group",
        count: 0,
        groupId,
        progress: createSyncProgressReporter()
      }),
      readCliFlag(["--json", "-j"])
    );
  })
);
dbSync.command("chat <chatId>").option("-n, --count <count>", "Recent messages to fetch for this chat", "200").option("-j, --json", "JSON output").description("Sync one chat (best-effort for direct-message history)").action(
  wrapAction(async (chatId, _opts, command) => {
    const count = resolveSyncWindowCount(readCliOptionValue(["--count", "-n"]));
    output(
      await runDbSync({
        command,
        mode: "chat",
        count,
        threadId: chatId,
        progress: createSyncProgressReporter()
      }),
      readCliFlag(["--json", "-j"])
    );
  })
);
var msg = program.command("msg").description("Messaging commands");
msg.command("send <threadId> <message>").option("-g, --group", "Send to group").option("--raw", "Send raw text without parsing formatting markers").option("--reply-id <id>", "Reply using a stored DB message id/msgId/cliMsgId").option("--reply-message <json>", "Reply using a raw message.data JSON object").description("Send text message with formatting (**bold** *italic* __bold__ ~~strike~~ {underline}text{/underline} {red}color{/red} {big}size{/big} lists indents). Group sends also resolve unique @Name/@userId mentions.").action(
  wrapAction(async (threadId, message, opts, command) => {
    const { api, profile } = await requireApi(command);
    const threadType = asThreadType(opts.group);
    const textPayload = await buildTextSendPayload({
      message,
      raw: opts.raw,
      threadType,
      threadId,
      listGroupMembers: threadType === ThreadType3.Group ? (groupId) => listGroupMentionMembers(api, groupId) : void 0
    });
    const quote = await resolveSendReplyQuote({
      profile,
      api,
      threadId,
      threadType,
      replyId: opts.replyId,
      replyMessage: opts.replyMessage
    });
    const payload = quote || typeof textPayload !== "string" ? {
      ...typeof textPayload === "string" ? { msg: textPayload } : textPayload,
      ...quote ? { quote } : {}
    } : textPayload;
    const deliveryPlan = planTextSendPayloadsForDelivery({
      payload: textPayload,
      threadType,
      threadId,
      maxMessageLength: parsePositiveIntFromEnv(
        "OPENZCA_TEXT_MESSAGE_MAX_LENGTH",
        ZALO_TEXT_MESSAGE_MAX_LENGTH
      ),
      maxRequestParamsLengthEstimate: parsePositiveIntFromEnv(
        "OPENZCA_TEXT_REQUEST_PARAMS_MAX_ESTIMATE",
        ZALO_TEXT_REQUEST_PARAMS_MAX_ESTIMATE
      )
    });
    const payloadChunks = deliveryPlan.chunks;
    const sendMessage = retrySendMethod(
      api.sendMessage.bind(api),
      command,
      (_payload, targetThreadId, targetThreadType) => ({
        kind: "msg.send",
        threadId: targetThreadId,
        threadType: targetThreadType === ThreadType3.Group ? "group" : "user"
      })
    );
    const responses = [];
    const sentPayloads = [];
    for (let index = 0; index < payloadChunks.length; index += 1) {
      const chunk = payloadChunks[index];
      const chunkPayload = quote && index === 0 ? {
        ...typeof chunk === "string" ? { msg: chunk } : chunk,
        quote
      } : chunk;
      sentPayloads.push(chunkPayload);
      responses.push(await sendMessage(chunkPayload, threadId, threadType));
    }
    const response = responses.length === 1 ? responses[0] : {
      chunked: true,
      chunkCount: responses.length,
      msgId: responses.at(-1)?.message?.msgId?.toString(),
      response: responses
    };
    output(response, false);
    if (await shouldWriteToDb(profile)) {
      scheduleDbWrite(profile, command, "msg.send.db.persist_error", async () => {
        if (payloadChunks.length === 1) {
          await persistOutgoingMessageBestEffort({
            profile,
            api,
            threadId,
            group: opts.group,
            text: message,
            msgType: "text",
            response,
            rawPayload: payload
          });
          return;
        }
        for (let index = 0; index < payloadChunks.length; index += 1) {
          const chunk = sentPayloads[index];
          const chunkText = typeof chunk === "string" ? chunk : chunk.msg;
          await persistOutgoingMessageBestEffort({
            profile,
            api,
            threadId,
            group: opts.group,
            text: chunkText,
            msgType: "text",
            response: responses[index],
            rawPayload: chunk
          });
        }
      });
    }
  })
);
msg.command("analyze-text <threadId> <message>").option("-g, --group", "Analyze as group text").option("--raw", "Analyze raw text without parsing formatting markers").option("-j, --json", "JSON output").description("Build and analyze the exact text payload that msg send would hand to zca-js. Useful for pre-send chunking/debugging.").action(
  wrapAction(async (threadId, message, opts, command) => {
    const threadType = asThreadType(opts.group);
    const mentionProbeText = opts.raw ? message : parseTextStyles(message).text;
    let listGroupMembers;
    if (threadType === ThreadType3.Group && hasPotentialOutboundGroupMention(mentionProbeText)) {
      const { api } = await requireApi(command);
      listGroupMembers = (groupId) => listGroupMentionMembers(api, groupId);
    }
    const analysis = await analyzeTextSendPayload({
      message,
      raw: opts.raw,
      threadType,
      threadId,
      listGroupMembers
    });
    output(analysis, shouldOutputJson(opts));
  })
);
msg.command("image <threadId> [file]").option("-u, --url <url>", "Image URL (repeatable)", collectValues, []).option("-m, --message <message>", "Caption").option("-g, --group", "Send to group").description("Send image(s) from file or URL").action(
  wrapAction(
    async (threadId, file, opts, command) => {
      const { api, profile } = await requireApi(command);
      const sendMessage = retrySendMethod(
        api.sendMessage.bind(api),
        command,
        (payload, targetThreadId, targetThreadType) => ({
          kind: "msg.image",
          threadId: targetThreadId,
          threadType: targetThreadType === ThreadType3.Group ? "group" : "user",
          attachmentCount: payload && typeof payload === "object" && Array.isArray(payload.attachments) ? payload.attachments.length : void 0
        })
      );
      const normalizedFile = file ? normalizeMediaInput(file) : void 0;
      const files = [normalizedFile, ...normalizeInputList(opts.url)].filter(Boolean);
      const urlInputs = files.filter((entry) => isHttpUrl(entry));
      const localInputs = files.filter((entry) => !isHttpUrl(entry));
      writeDebugLine(
        "msg.image.inputs",
        {
          threadId,
          isGroup: Boolean(opts.group),
          localInputs,
          urlInputs
        },
        command
      );
      const downloaded = await downloadUrlsToTempFiles(urlInputs);
      try {
        const attachments = [...localInputs, ...downloaded.files];
        if (attachments.length === 0) {
          throw new Error("Provide at least one image file or --url.");
        }
        await assertFilesExist(attachments);
        const response = await sendMessage(
          {
            msg: opts.message ?? "",
            attachments
          },
          threadId,
          asThreadType(opts.group)
        );
        output(response, false);
        if (await shouldWriteToDb(profile)) {
          scheduleDbWrite(profile, command, "msg.image.db.persist_error", async () => {
            await persistOutgoingMessageBestEffort({
              profile,
              api,
              threadId,
              group: opts.group,
              text: opts.message ?? "",
              msgType: "image",
              response,
              rawPayload: {
                msg: opts.message ?? "",
                attachments
              },
              media: attachments.map((item) => ({
                mediaKind: "image",
                mediaPath: isHttpUrl(item) ? void 0 : item,
                mediaUrl: isHttpUrl(item) ? item : void 0
              }))
            });
          });
        }
      } finally {
        await downloaded.cleanup();
      }
    }
  )
);
msg.command("video <threadId> [file]").option("-u, --url <url>", "Video URL (repeatable)", collectValues, []).option("-m, --message <message>", "Caption").option("--thumbnail <pathOrUrl>", "Thumbnail image path or URL (optional)").option("-g, --group", "Send to group").description("Send video(s) from file or URL").action(
  wrapAction(
    async (threadId, file, opts, command) => {
      const { api, profile } = await requireApi(command);
      const threadType = asThreadType(opts.group);
      const sendMessage = retrySendMethod(
        api.sendMessage.bind(api),
        command,
        (payload, targetThreadId, targetThreadType) => ({
          kind: "msg.video.fallback",
          threadId: targetThreadId,
          threadType: targetThreadType === ThreadType3.Group ? "group" : "user",
          attachmentCount: payload && typeof payload === "object" && Array.isArray(payload.attachments) ? payload.attachments.length : void 0
        })
      );
      const normalizedFile = file ? normalizeMediaInput(file) : void 0;
      const files = [normalizedFile, ...normalizeInputList(opts.url)].filter(Boolean);
      const urlInputs = files.filter((entry) => isHttpUrl(entry));
      const localInputs = files.filter((entry) => !isHttpUrl(entry));
      const normalizedThumbnail = opts.thumbnail ? normalizeMediaInput(opts.thumbnail) : void 0;
      const thumbnailUrlInputs = normalizedThumbnail && isHttpUrl(normalizedThumbnail) ? [normalizedThumbnail] : [];
      const thumbnailLocalPath = normalizedThumbnail && !isHttpUrl(normalizedThumbnail) ? normalizedThumbnail : void 0;
      writeDebugLine(
        "msg.video.inputs",
        {
          threadId,
          isGroup: Boolean(opts.group),
          localInputs,
          urlInputs,
          thumbnail: normalizedThumbnail
        },
        command
      );
      const downloaded = await downloadUrlsToTempFiles(urlInputs);
      const downloadedThumbnail = await downloadUrlsToTempFiles(thumbnailUrlInputs);
      try {
        const attachments = [...localInputs, ...downloaded.files];
        if (attachments.length === 0) {
          throw new Error("Provide at least one video file or --url.");
        }
        await assertFilesExist(attachments);
        if (thumbnailLocalPath) {
          await assertFilesExist([thumbnailLocalPath]);
        }
        const enforceSingleOwner = parseBooleanFromEnv("OPENZCA_UPLOAD_ENFORCE_SINGLE_OWNER", true);
        if (enforceSingleOwner) {
          const owner = await readActiveListenerOwner(profile);
          if (owner && owner.pid !== process.pid) {
            throw new Error(
              `Active listener owner detected for profile "${profile}" (pid ${owner.pid}), but video upload IPC is unavailable. Restart \`openzca listen\` with latest version or set OPENZCA_UPLOAD_ENFORCE_SINGLE_OWNER=0 to allow fallback listener startup.`
            );
          }
        }
        const ffmpegAvailable = await isFfmpegAvailable();
        const videoPlan = planVideoSendMode({
          files: attachments,
          ffmpegAvailable
        });
        if (videoPlan.mode === "native") {
          const thumbnailPath = thumbnailLocalPath ?? downloadedThumbnail.files[0];
          try {
            const response2 = await withUploadListener(
              api,
              command,
              async () => sendNativeVideo({
                api,
                threadId,
                threadType,
                videoPath: attachments[0],
                message: opts.message,
                thumbnailPath
              })
            );
            writeDebugLine(
              "msg.video.native.success",
              {
                threadId,
                isGroup: Boolean(opts.group),
                videoPath: attachments[0],
                thumbnailPath: thumbnailPath ?? null
              },
              command
            );
            output(response2, false);
            if (await shouldWriteToDb(profile)) {
              scheduleDbWrite(profile, command, "msg.video.db.persist_error", async () => {
                await persistOutgoingMessageBestEffort({
                  profile,
                  api,
                  threadId,
                  group: opts.group,
                  text: opts.message ?? "",
                  msgType: "video",
                  response: response2,
                  rawPayload: {
                    msg: opts.message ?? "",
                    videoPath: attachments[0],
                    thumbnailPath: thumbnailPath ?? null
                  },
                  media: [
                    {
                      mediaKind: "video",
                      mediaPath: attachments[0]
                    }
                  ]
                });
              });
            }
            return;
          } catch (error) {
            writeDebugLine(
              "msg.video.native.failed",
              {
                threadId,
                isGroup: Boolean(opts.group),
                videoPath: attachments[0],
                thumbnailPath: thumbnailPath ?? null,
                message: error instanceof Error ? error.message : String(error)
              },
              command
            );
          }
        } else {
          writeDebugLine(
            "msg.video.native.skipped",
            {
              threadId,
              isGroup: Boolean(opts.group),
              reason: videoPlan.reason
            },
            command
          );
        }
        const response = await withUploadListener(
          api,
          command,
          async () => sendMessage(
            {
              msg: opts.message ?? "",
              attachments
            },
            threadId,
            threadType
          )
        );
        output(response, false);
        if (await shouldWriteToDb(profile)) {
          scheduleDbWrite(profile, command, "msg.video.db.persist_error", async () => {
            await persistOutgoingMessageBestEffort({
              profile,
              api,
              threadId,
              group: opts.group,
              text: opts.message ?? "",
              msgType: "video",
              response,
              rawPayload: {
                msg: opts.message ?? "",
                attachments
              },
              media: attachments.map((item) => ({
                mediaKind: "video",
                mediaPath: isHttpUrl(item) ? void 0 : item,
                mediaUrl: isHttpUrl(item) ? item : void 0
              }))
            });
          });
        }
      } finally {
        await downloaded.cleanup();
        await downloadedThumbnail.cleanup();
      }
    }
  )
);
msg.command("voice <threadId> [file]").option("-u, --url <url>", "Voice URL (repeatable)", collectValues, []).option("-g, --group", "Send to group").description("Send voice message from file or URL").action(
  wrapAction(
    async (threadId, file, opts, command) => {
      const { api, profile } = await requireApi(command);
      const type = asThreadType(opts.group);
      const sendVoice = retrySendMethod(
        api.sendVoice.bind(api),
        command,
        (_payload, targetThreadId, targetThreadType) => ({
          kind: "msg.voice",
          threadId: targetThreadId,
          threadType: targetThreadType === ThreadType3.Group ? "group" : "user"
        })
      );
      const normalizedFile = file ? normalizeMediaInput(file) : void 0;
      const files = [normalizedFile, ...normalizeInputList(opts.url)].filter(Boolean);
      if (files.length === 0) {
        throw new Error("Provide a voice file or --url.");
      }
      const urlInputs = files.filter((entry) => isHttpUrl(entry));
      const localInputs = files.filter((entry) => !isHttpUrl(entry));
      const publishCommand = getVoicePublishCommandFromEnv();
      const ffmpegAvailable = localInputs.length > 0 && publishCommand ? await isFfmpegAvailable() : false;
      const usePublishFlow = localInputs.length > 0 && Boolean(publishCommand) && ffmpegAvailable;
      writeDebugLine(
        "msg.voice.inputs",
        {
          threadId,
          isGroup: Boolean(opts.group),
          localInputs,
          urlInputs,
          publishConfigured: Boolean(publishCommand),
          ffmpegAvailable: localInputs.length > 0 ? ffmpegAvailable : void 0,
          mode: usePublishFlow ? "publish" : "legacy"
        },
        command
      );
      await assertFilesExist(localInputs);
      const publishedLocals = [];
      let uploadedLocals = [];
      if (usePublishFlow) {
        for (const localInput of localInputs) {
          const normalized = await normalizeVoiceForPublish(localInput);
          try {
            const mediaUrl = await publishVoiceFile(publishCommand, normalized.path);
            publishedLocals.push({
              mediaPath: localInput,
              mediaUrl
            });
          } finally {
            await normalized.cleanup();
          }
        }
      } else if (localInputs.length > 0) {
        uploadedLocals = await withUploadListener(
          api,
          command,
          async () => api.uploadAttachment(localInputs, threadId, type)
        );
      }
      const pendingPublished = [...publishedLocals];
      const pendingUploaded = [...uploadedLocals];
      const outboundVoices = [];
      for (const entry of files) {
        if (isHttpUrl(entry)) {
          outboundVoices.push({
            mediaUrl: entry
          });
          continue;
        }
        if (usePublishFlow) {
          const nextPublished = pendingPublished.shift();
          if (!nextPublished) {
            throw new Error(`Voice publish flow lost local file mapping for: ${entry}`);
          }
          outboundVoices.push(nextPublished);
          continue;
        }
        const nextUploaded = pendingUploaded.shift();
        if (!nextUploaded) {
          throw new Error(`Voice upload flow lost local file mapping for: ${entry}`);
        }
        if (nextUploaded.fileType === "others" || nextUploaded.fileType === "video") {
          outboundVoices.push({
            mediaPath: entry,
            mediaUrl: nextUploaded.fileUrl,
            rawJson: JSON.stringify(nextUploaded)
          });
        }
      }
      if (outboundVoices.length === 0) {
        throw new Error(
          "No valid voice attachment generated. Use an audio file (e.g. .aac, .mp3, .m4a, .wav, .ogg)."
        );
      }
      const results = [];
      for (const item of outboundVoices) {
        results.push(await sendVoice({ voiceUrl: item.mediaUrl }, threadId, type));
      }
      output(results, false);
      if (await shouldWriteToDb(profile)) {
        scheduleDbWrite(profile, command, "msg.voice.db.persist_error", async () => {
          await persistOutgoingMessageBestEffort({
            profile,
            api,
            threadId,
            group: opts.group,
            msgType: "voice",
            response: results,
            rawPayload: {
              mode: usePublishFlow ? "publish" : "legacy",
              directUrls: urlInputs,
              published: publishedLocals,
              uploaded: uploadedLocals
            },
            media: outboundVoices.map((item) => ({
              mediaKind: "voice",
              mediaPath: item.mediaPath,
              mediaUrl: item.mediaUrl,
              rawJson: item.rawJson
            }))
          });
        });
      }
    }
  )
);
msg.command("sticker <threadId> <stickerId>").option("-g, --group", "Send to group").description("Send a sticker by sticker ID").action(
  wrapAction(
    async (threadId, stickerId, opts, command) => {
      const { api } = await requireApi(command);
      const details = await api.getStickersDetail(Number(stickerId));
      const first = details[0];
      if (!first) {
        throw new Error(`Sticker ${stickerId} not found.`);
      }
      const response = await api.sendSticker(
        {
          id: Number(first.id),
          cateId: Number(first.cateId),
          type: Number(first.type)
        },
        threadId,
        asThreadType(opts.group)
      );
      output(response, false);
    }
  )
);
msg.command("link <threadId> <url>").option("-g, --group", "Send to group").description("Send link").action(
  wrapAction(async (threadId, url, opts, command) => {
    const { api, profile } = await requireApi(command);
    const sendLink = retrySendMethod(
      api.sendLink.bind(api),
      command,
      (_payload, targetThreadId, targetThreadType) => ({
        kind: "msg.link",
        threadId: targetThreadId,
        threadType: targetThreadType === ThreadType3.Group ? "group" : "user"
      })
    );
    const response = await sendLink({ link: url }, threadId, asThreadType(opts.group));
    output(response, false);
    if (await shouldWriteToDb(profile)) {
      scheduleDbWrite(profile, command, "msg.link.db.persist_error", async () => {
        await persistOutgoingMessageBestEffort({
          profile,
          api,
          threadId,
          group: opts.group,
          text: url,
          msgType: "link",
          response,
          rawPayload: { link: url }
        });
      });
    }
  })
);
msg.command("card <threadId> <contactId>").option("-g, --group", "Send to group").description("Send contact card").action(
  wrapAction(
    async (threadId, contactId, opts, command) => {
      const { api } = await requireApi(command);
      const response = await api.sendCard(
        { userId: contactId },
        threadId,
        asThreadType(opts.group)
      );
      output(response, false);
    }
  )
);
msg.command("react <msgId> <cliMsgId> <threadId> <reaction>").option("-g, --group", "React in group").description("React to a message").action(
  wrapAction(
    async (msgId, cliMsgId, threadId, reaction, opts, command) => {
      const { api } = await requireApi(command);
      const response = await api.addReaction(parseReaction(reaction), {
        data: {
          msgId,
          cliMsgId
        },
        threadId,
        type: asThreadType(opts.group)
      });
      output(response, false);
    }
  )
);
msg.command("typing <threadId>").option("-g, --group", "Typing in group").description("Send typing event").action(
  wrapAction(async (threadId, opts, command) => {
    const { api } = await requireApi(command);
    const response = await api.sendTypingEvent(
      threadId,
      asThreadType(opts.group),
      DestType.User
    );
    output(response, false);
  })
);
msg.command("forward <message> <targets...>").option("-g, --group", "Forward to groups").description("Forward text to multiple targets").action(
  wrapAction(
    async (message, targets, opts, command) => {
      const { api } = await requireApi(command);
      const response = await api.forwardMessage(
        { message },
        targets,
        asThreadType(opts.group)
      );
      output(response, false);
    }
  )
);
msg.command("delete <msgId> <cliMsgId> <uidFrom> <threadId>").option("-g, --group", "Delete in group").option("--only-me", "Delete only for yourself").description("Delete message").action(
  wrapAction(
    async (msgId, cliMsgId, uidFrom, threadId, opts, command) => {
      const { api } = await requireApi(command);
      const response = await api.deleteMessage(
        {
          data: {
            msgId,
            cliMsgId,
            uidFrom
          },
          threadId,
          type: asThreadType(opts.group)
        },
        Boolean(opts.onlyMe)
      );
      output(response, false);
    }
  )
);
msg.command("undo <msgId> <cliMsgId> <threadId>").option("-g, --group", "Undo in group").description("Recall your sent message").action(
  wrapAction(
    async (msgId, cliMsgId, threadId, opts, command) => {
      const { api } = await requireApi(command);
      const response = await api.undo(
        {
          msgId,
          cliMsgId
        },
        threadId,
        asThreadType(opts.group)
      );
      output(response, false);
    }
  )
);
msg.command("edit <msgId> <cliMsgId> <threadId> <message>").option("-g, --group", "Edit in group").description("Edit message (compatibility shim: recall old message then resend new text)").action(
  wrapAction(
    async (msgId, cliMsgId, threadId, message, opts, command) => {
      const { api } = await requireApi(command);
      const type = asThreadType(opts.group);
      const sendMessage = retrySendMethod(
        api.sendMessage.bind(api),
        command,
        (_payload, targetThreadId, targetThreadType) => ({
          kind: "msg.edit.resend",
          threadId: targetThreadId,
          threadType: targetThreadType === ThreadType3.Group ? "group" : "user"
        })
      );
      const undoResponse = await api.undo(
        {
          msgId,
          cliMsgId
        },
        threadId,
        type
      );
      const sendResponse = await sendMessage(message, threadId, type);
      output(
        {
          mode: "undo+send",
          nativeEditSupported: false,
          undo: undoResponse,
          send: sendResponse
        },
        false
      );
    }
  )
);
msg.command("upload <arg1> [arg2]").option("-u, --url <url>", "File URL (repeatable)", collectValues, []).option("-g, --group", "Upload in group").description("Upload and send file(s)").action(
  wrapAction(
    async (arg1, arg2, opts, command) => {
      const { api, profile } = await requireApi(command);
      const sendMessage = retrySendMethod(
        api.sendMessage.bind(api),
        command,
        (payload, targetThreadId, targetThreadType) => ({
          kind: "msg.upload",
          threadId: targetThreadId,
          threadType: targetThreadType === ThreadType3.Group ? "group" : "user",
          attachmentCount: payload && typeof payload === "object" && Array.isArray(payload.attachments) ? payload.attachments.length : void 0
        })
      );
      const inputs = normalizeInputList(opts.url);
      const urlInputs = inputs.filter((entry) => isHttpUrl(entry));
      const localInputs = inputs.filter((entry) => !isHttpUrl(entry));
      const [threadId, file] = arg2 ? [arg2, arg1] : [arg1, void 0];
      const threadResolution = await resolveUploadThreadType(
        api,
        profile,
        threadId,
        opts.group,
        command
      );
      const normalizedFile = file ? normalizeMediaInput(file) : void 0;
      const localFiles = [normalizedFile, ...localInputs].filter(Boolean);
      writeDebugLine(
        "msg.upload.inputs",
        {
          threadId,
          explicitGroupFlag: Boolean(opts.group),
          isGroup: threadResolution.type === ThreadType3.Group,
          threadType: threadResolution.type === ThreadType3.Group ? "group" : "user",
          threadTypeReason: threadResolution.reason,
          localFiles,
          urlInputs
        },
        command
      );
      const downloaded = await downloadUrlsToTempFiles(urlInputs);
      try {
        const attachments = [...localFiles, ...downloaded.files];
        if (attachments.length === 0) {
          throw new Error(
            "Provide file and threadId (upload <file> <threadId>) or use --url."
          );
        }
        await assertFilesExist(attachments);
        const ipcResult = await tryUploadViaListenerIpc(
          profile,
          threadId,
          threadResolution.type,
          attachments,
          command
        );
        if (ipcResult.handled) {
          writeDebugLine(
            "msg.upload.ipc.done",
            {
              threadId,
              threadType: threadResolution.type === ThreadType3.Group ? "group" : "user"
            },
            command
          );
          output(ipcResult.response, false);
          return;
        }
        writeDebugLine(
          "msg.upload.ipc.fallback",
          {
            threadId,
            threadType: threadResolution.type === ThreadType3.Group ? "group" : "user",
            reason: ipcResult.reason
          },
          command
        );
        const enforceSingleOwner = parseBooleanFromEnv("OPENZCA_UPLOAD_ENFORCE_SINGLE_OWNER", true);
        if (enforceSingleOwner) {
          const owner = await readActiveListenerOwner(profile);
          if (owner && owner.pid !== process.pid) {
            throw new Error(
              `Active listener owner detected for profile "${profile}" (pid ${owner.pid}), but upload IPC is unavailable. Restart \`openzca listen\` with latest version or set OPENZCA_UPLOAD_ENFORCE_SINGLE_OWNER=0 to allow fallback listener startup.`
            );
          }
        }
        const response = await withUploadListener(
          api,
          command,
          async () => sendMessage(
            {
              msg: "",
              attachments
            },
            threadId,
            threadResolution.type
          )
        );
        output(response, false);
      } finally {
        await downloaded.cleanup();
      }
    }
  )
);
msg.command("recent <threadId>").option("-g, --group", "List recent messages for group thread").option("-n, --count <count>", "Number of messages", "20").option("--source <source>", "Message source: live, db, or auto", "live").option("-j, --json", "JSON output").description("List recent messages (group uses direct history API when available)").action(
  wrapAction(
    async (threadId, opts, command) => {
      const { api, profile } = await requireApi(command);
      const parsedCount = Number(opts.count);
      const count = Number.isFinite(parsedCount) ? Math.min(Math.max(Math.trunc(parsedCount), 1), 200) : 20;
      const threadType = opts.group ? ThreadType3.Group : ThreadType3.User;
      const source = (opts.source ?? "live").trim().toLowerCase();
      if (!["live", "db", "auto"].includes(source)) {
        throw new Error("--source must be one of: live, db, auto");
      }
      let rows = source === "db" || source === "auto" ? await listRecentMessages({
        profile,
        threadId,
        threadType: opts.group ? "group" : "user",
        count
      }) : [];
      if (source === "live" || source === "auto" && rows.length === 0) {
        const messages = opts.group ? await fetchRecentGroupMessagesViaApi(api, threadId, count) : await fetchRecentUserMessagesViaListener(
          api,
          threadId,
          count
        );
        rows = messages.map((message) => ({
          msgId: message.data.msgId,
          cliMsgId: message.data.cliMsgId,
          threadId: message.threadId || threadId,
          threadType: message.type === ThreadType3.Group ? "group" : "user",
          senderId: message.data.uidFrom,
          senderName: message.data.dName ?? "",
          ts: message.data.ts,
          msgType: message.data.msgType,
          undo: {
            msgId: message.data.msgId,
            cliMsgId: message.data.cliMsgId,
            threadId: message.threadId || threadId,
            group: message.type === ThreadType3.Group
          },
          content: typeof message.data.content === "string" ? message.data.content : JSON.stringify(message.data.content)
        }));
      }
      if (opts.json) {
        output(
          {
            threadId,
            threadType: threadType === ThreadType3.Group ? "group" : "user",
            count: rows.length,
            messages: rows
          },
          true
        );
        return;
      }
      output(rows, false);
    }
  )
);
msg.command("pin <threadId>").option("-g, --group", "Pin group conversation").description("Pin conversation").action(
  wrapAction(async (threadId, opts, command) => {
    const { api } = await requireApi(command);
    const type = asThreadType(opts.group);
    const response = await api.setPinnedConversations(true, threadId, type);
    output(
      {
        threadId,
        threadType: type === ThreadType3.Group ? "group" : "user",
        pinned: true,
        response
      },
      false
    );
  })
);
msg.command("unpin <threadId>").option("-g, --group", "Unpin group conversation").description("Unpin conversation").action(
  wrapAction(async (threadId, opts, command) => {
    const { api } = await requireApi(command);
    const type = asThreadType(opts.group);
    const response = await api.setPinnedConversations(false, threadId, type);
    output(
      {
        threadId,
        threadType: type === ThreadType3.Group ? "group" : "user",
        pinned: false,
        response
      },
      false
    );
  })
);
msg.command("list-pins").option("-j, --json", "JSON output").description("List pinned conversations").action(
  wrapAction(async (opts, command) => {
    const { api } = await requireApi(command);
    const response = await api.getPinConversations();
    if (opts.json) {
      output(response, true);
      return;
    }
    output(
      response.conversations.map((threadId) => ({
        threadId,
        pinned: true
      })),
      false
    );
  })
);
msg.command("member-info <userId>").option("-j, --json", "JSON output").description("Get member/user profile info").action(
  wrapAction(async (userId, opts, command) => {
    const { api } = await requireApi(command);
    const response = await api.getUserInfo(userId);
    if (opts.json) {
      output(response, true);
      return;
    }
    const profiles = response.changed_profiles ?? {};
    const matchedProfile = profiles[userId] ?? profiles[`${userId}_0`] ?? Object.values(profiles)[0] ?? null;
    output(
      {
        userId,
        found: Boolean(matchedProfile),
        profile: matchedProfile
      },
      false
    );
  })
);
var group = program.command("group").description("Group management");
group.command("list").option("-j, --json", "JSON output").description("List groups").action(
  wrapAction(async (opts, command) => {
    const { api } = await requireApi(command);
    const groups = await buildGroupsDetailed(api);
    if (opts.json) {
      output(groups, true);
      return;
    }
    output(
      groups.map((item) => ({
        groupId: item.groupId,
        name: item.name,
        totalMember: item.totalMember,
        type: item.type
      }))
    );
  })
);
group.command("info <groupId>").description("Get group info").action(
  wrapAction(async (groupId, command) => {
    const { api } = await requireApi(command);
    const data = await api.getGroupInfo(groupId);
    output(data.gridInfoMap[groupId], false);
  })
);
group.command("members <groupId>").option("-j, --json", "JSON output").description("List group members").action(
  wrapAction(async (groupId, opts, command) => {
    const { api } = await requireApi(command);
    const rows = await listGroupMemberRows(api, groupId);
    if (opts.json) {
      output(rows, true);
      return;
    }
    output(rows, false);
  })
);
group.command("create <name> <members...>").description("Create new group").action(
  wrapAction(async (name, members, command) => {
    const { api } = await requireApi(command);
    const response = await api.createGroup({
      name,
      members
    });
    output(response, false);
  })
);
var groupPoll = group.command("poll").description("Group poll management");
groupPoll.command("create <groupId>").requiredOption("-q, --question <text>", "Poll question").requiredOption("-o, --option <text>", "Poll option (repeatable)", collectValues, []).option("-j, --json", "JSON output").option("--multi", "Allow multiple choices").option("--allow-add-option", "Allow members to add new options").option("--hide-vote-preview", "Hide results until the member votes").option("--anonymous", "Hide voters").option("--expire-ms <ms>", "Poll expiration time in milliseconds").description("Create a poll in a group").action(
  wrapAction(
    async (groupId, opts, command) => {
      const pollOptions = buildCreatePollOptions(opts);
      const { api } = await requireApi(command);
      output(await api.createPoll(pollOptions, groupId), shouldOutputJson(opts));
    }
  )
);
groupPoll.command("detail <pollId>").option("-j, --json", "JSON output").description("Get poll detail").action(
  wrapAction(async (pollId, opts, command) => {
    const normalizedPollId = parsePollId(pollId);
    const { api } = await requireApi(command);
    output(await api.getPollDetail(normalizedPollId), shouldOutputJson(opts));
  })
);
groupPoll.command("vote <pollId>").requiredOption("-o, --option <id>", "Poll option id (repeatable)", collectValues, []).option("-j, --json", "JSON output").description("Vote on a group poll").action(
  wrapAction(
    async (pollId, opts, command) => {
      const normalizedPollId = parsePollId(pollId);
      const optionIds = parsePollOptionIds(opts.option);
      const { api } = await requireApi(command);
      const response = await api.votePoll(
        normalizedPollId,
        optionIds.length === 1 ? optionIds[0] : optionIds
      );
      output(response, shouldOutputJson(opts));
    }
  )
);
groupPoll.command("lock <pollId>").option("-j, --json", "JSON output").description("Close a poll").action(
  wrapAction(async (pollId, opts, command) => {
    const normalizedPollId = parsePollId(pollId);
    const { api } = await requireApi(command);
    output(await api.lockPoll(normalizedPollId), shouldOutputJson(opts));
  })
);
groupPoll.command("share <pollId>").option("-j, --json", "JSON output").description("Share a poll").action(
  wrapAction(async (pollId, opts, command) => {
    const normalizedPollId = parsePollId(pollId);
    const { api } = await requireApi(command);
    output(await api.sharePoll(normalizedPollId), shouldOutputJson(opts));
  })
);
group.command("rename <groupId> <name>").description("Rename group").action(
  wrapAction(async (groupId, name, command) => {
    const { api } = await requireApi(command);
    const response = await api.changeGroupName(name, groupId);
    output(response, false);
  })
);
group.command("avatar <groupId> <file>").description("Change group avatar").action(
  wrapAction(async (groupId, file, command) => {
    const { api } = await requireApi(command);
    const normalizedFile = normalizeMediaInput(file);
    await assertFilesExist([normalizedFile]);
    const response = await api.changeGroupAvatar(normalizedFile, groupId);
    output(response, false);
  })
);
group.command("settings <groupId>").option("--lock-name", "Lock group name/avatar").option("--unlock-name", "Unlock group name/avatar").option("--sign-admin", "Highlight admin messages").option("--no-sign-admin", "Disable admin message highlight").description("Update group settings").action(
  wrapAction(
    async (groupId, opts, command) => {
      if (!opts.lockName && !opts.unlockName && !opts.signAdmin && !opts.noSignAdmin) {
        throw new Error("Provide at least one setting option.");
      }
      const { api } = await requireApi(command);
      const current = (await api.getGroupInfo(groupId)).gridInfoMap[groupId]?.setting;
      if (!current) {
        throw new Error(`Group not found: ${groupId}`);
      }
      const payload = {
        blockName: Boolean(current.blockName),
        signAdminMsg: Boolean(current.signAdminMsg),
        setTopicOnly: Boolean(current.setTopicOnly),
        enableMsgHistory: Boolean(current.enableMsgHistory),
        joinAppr: Boolean(current.joinAppr),
        lockCreatePost: Boolean(current.lockCreatePost),
        lockCreatePoll: Boolean(current.lockCreatePoll),
        lockSendMsg: Boolean(current.lockSendMsg),
        lockViewMember: Boolean(current.lockViewMember)
      };
      if (opts.lockName) payload.blockName = true;
      if (opts.unlockName) payload.blockName = false;
      if (opts.signAdmin) payload.signAdminMsg = true;
      if (opts.noSignAdmin) payload.signAdminMsg = false;
      const response = await api.updateGroupSettings(payload, groupId);
      output(response, false);
    }
  )
);
group.command("add <groupId> <userIds...>").description("Add users to group").action(
  wrapAction(async (groupId, userIds, command) => {
    const { api } = await requireApi(command);
    const response = await api.addUserToGroup(userIds, groupId);
    output(response, false);
  })
);
group.command("remove <groupId> <userIds...>").description("Remove users from group").action(
  wrapAction(async (groupId, userIds, command) => {
    const { api } = await requireApi(command);
    const response = await api.removeUserFromGroup(userIds, groupId);
    output(response, false);
  })
);
group.command("add-deputy <groupId> <userId>").description("Promote deputy").action(
  wrapAction(async (groupId, userId, command) => {
    const { api } = await requireApi(command);
    const response = await api.addGroupDeputy(userId, groupId);
    output(response, false);
  })
);
group.command("remove-deputy <groupId> <userId>").description("Demote deputy").action(
  wrapAction(async (groupId, userId, command) => {
    const { api } = await requireApi(command);
    const response = await api.removeGroupDeputy(userId, groupId);
    output(response, false);
  })
);
group.command("transfer <groupId> <newOwnerId>").description("Transfer ownership").action(
  wrapAction(async (groupId, newOwnerId, command) => {
    const { api } = await requireApi(command);
    const response = await api.changeGroupOwner(newOwnerId, groupId);
    output(response, false);
  })
);
group.command("block <groupId> <userId>").description("Block member").action(
  wrapAction(async (groupId, userId, command) => {
    const { api } = await requireApi(command);
    const response = await api.addGroupBlockedMember(userId, groupId);
    output(response, false);
  })
);
group.command("unblock <groupId> <userId>").description("Unblock member").action(
  wrapAction(async (groupId, userId, command) => {
    const { api } = await requireApi(command);
    const response = await api.removeGroupBlockedMember(userId, groupId);
    output(response, false);
  })
);
group.command("blocked <groupId>").description("List blocked members").action(
  wrapAction(async (groupId, command) => {
    const { api } = await requireApi(command);
    const response = await api.getGroupBlockedMember({}, groupId);
    output(response.blocked_members, false);
  })
);
group.command("enable-link <groupId>").description("Enable invite link").action(
  wrapAction(async (groupId, command) => {
    const { api } = await requireApi(command);
    output(await api.enableGroupLink(groupId), false);
  })
);
group.command("disable-link <groupId>").description("Disable invite link").action(
  wrapAction(async (groupId, command) => {
    const { api } = await requireApi(command);
    output(await api.disableGroupLink(groupId), false);
  })
);
group.command("link-detail <groupId>").description("Get invite link detail").action(
  wrapAction(async (groupId, command) => {
    const { api } = await requireApi(command);
    output(await api.getGroupLinkDetail(groupId), false);
  })
);
group.command("join-link <linkId>").description("Join by invite link").action(
  wrapAction(async (linkId, command) => {
    const { api } = await requireApi(command);
    output(await api.joinGroupLink(linkId), false);
  })
);
group.command("pending <groupId>").description("List pending member requests").action(
  wrapAction(async (groupId, command) => {
    const { api } = await requireApi(command);
    output(await api.getPendingGroupMembers(groupId), false);
  })
);
group.command("review <groupId> <userId> <action>").description("Approve or deny pending request").action(
  wrapAction(
    async (groupId, userId, action, command) => {
      const normalized = action.trim().toLowerCase();
      if (!["approve", "deny"].includes(normalized)) {
        throw new Error('Action must be "approve" or "deny".');
      }
      const { api } = await requireApi(command);
      const result = await api.reviewPendingMemberRequest(
        {
          members: userId,
          isApprove: normalized === "approve"
        },
        groupId
      );
      const status = result[userId];
      if (status === ReviewPendingMemberRequestStatus.SUCCESS) {
        console.log(`${normalized} success for user ${userId}`);
      } else {
        output(result, false);
      }
    }
  )
);
group.command("leave <groupId>").description("Leave group").action(
  wrapAction(async (groupId, command) => {
    const { api } = await requireApi(command);
    output(await api.leaveGroup(groupId), false);
  })
);
group.command("disperse <groupId>").description("Disperse group").action(
  wrapAction(async (groupId, command) => {
    const { api } = await requireApi(command);
    output(await api.disperseGroup(groupId), false);
  })
);
var friend = program.command("friend").description("Friend management");
friend.command("list").option("-j, --json", "JSON output").description("List all friends").action(
  wrapAction(async (opts, command) => {
    const { api } = await requireApi(command);
    const friends = await api.getAllFriends();
    if (opts.json) {
      output(friends, true);
      return;
    }
    output(
      friends.map((item) => ({
        userId: item.userId,
        displayName: item.displayName,
        username: item.username,
        phone: item.phoneNumber
      }))
    );
  })
);
friend.command("find <query>").option("-j, --json", "JSON output").description("Find user by phone/username/name").action(
  wrapAction(async (query, opts, command) => {
    const { profile, api } = await requireApi(command);
    let result;
    if (/^\d+$/.test(query.replace(/^\+/, ""))) {
      result = await api.findUser(query);
    } else {
      try {
        const withUsernameSearch = api;
        result = await withUsernameSearch.findUserByUsername(query);
      } catch {
        const cache = await readCache(profile);
        const lowered = query.toLowerCase();
        const friends = cache.friends;
        const matched = friends.filter(
          (item) => [
            item.displayName,
            item.zaloName,
            item.username,
            item.userId,
            item.phoneNumber
          ].filter(Boolean).some((value) => String(value).toLowerCase().includes(lowered))
        );
        result = matched;
      }
    }
    const rows = normalizeFriendLookupRows(result);
    const shouldJson = Boolean(opts.json) || !process.stdout.isTTY;
    if (shouldJson) {
      output(rows, true);
      return;
    }
    if (rows.length > 0) {
      output(rows, false);
      return;
    }
    output(result, false);
  })
);
friend.command("online").option("-j, --json", "JSON output").description("List online friends").action(
  wrapAction(async (opts, command) => {
    const { api } = await requireApi(command);
    try {
      const data = await api.getFriendOnlines();
      output(data.onlines, Boolean(opts.json));
    } catch (error) {
      const friends = await api.getAllFriends();
      const fallback = friends.filter(
        (friendItem) => Number(friendItem.isActive) === 1 || Number(friendItem.isActiveWeb) === 1 || Number(friendItem.isActivePC) === 1
      ).map((friendItem) => ({
        userId: friendItem.userId,
        status: "online",
        displayName: friendItem.displayName,
        source: "fallback_active_flags"
      }));
      console.error(
        `Warning: friend online fallback used (${error instanceof Error ? error.message : String(error)})`
      );
      output(fallback, Boolean(opts.json));
    }
  })
);
friend.command("recommendations").option("-j, --json", "JSON output").description("Get recommendations").action(
  wrapAction(async (opts, command) => {
    const { api } = await requireApi(command);
    const data = await api.getFriendRecommendations();
    output(data.recommItems, Boolean(opts.json));
  })
);
friend.command("add <userId>").option("-m, --message <message>", "Request message", "Hello!").description("Send friend request").action(
  wrapAction(
    async (userId, opts, command) => {
      const { api } = await requireApi(command);
      output(await api.sendFriendRequest(opts.message, userId), false);
    }
  )
);
friend.command("accept <userId>").description("Accept friend request").action(
  wrapAction(async (userId, command) => {
    const { api } = await requireApi(command);
    output(await api.acceptFriendRequest(userId), false);
  })
);
friend.command("reject <userId>").description("Reject friend request").action(
  wrapAction(async (userId, command) => {
    const { api } = await requireApi(command);
    output(await api.rejectFriendRequest(userId), false);
  })
);
friend.command("cancel <userId>").description("Cancel sent friend request").action(
  wrapAction(async (userId, command) => {
    const { api } = await requireApi(command);
    output(await api.undoFriendRequest(userId), false);
  })
);
friend.command("sent").option("-j, --json", "JSON output").description("List sent requests").action(
  wrapAction(async (opts, command) => {
    const { api } = await requireApi(command);
    output(await api.getSentFriendRequest(), Boolean(opts.json));
  })
);
friend.command("request-status <userId>").description("Request status for user").action(
  wrapAction(async (userId, command) => {
    const { api } = await requireApi(command);
    output(await api.getFriendRequestStatus(userId), false);
  })
);
friend.command("remove <userId>").description("Remove friend").action(
  wrapAction(async (userId, command) => {
    const { api } = await requireApi(command);
    output(await api.removeFriend(userId), false);
  })
);
friend.command("alias <userId> <alias>").description("Set friend alias").action(
  wrapAction(async (userId, alias, command) => {
    const { api } = await requireApi(command);
    output(await api.changeFriendAlias(alias, userId), false);
  })
);
friend.command("remove-alias <userId>").description("Remove friend alias").action(
  wrapAction(async (userId, command) => {
    const { api } = await requireApi(command);
    output(await api.removeFriendAlias(userId), false);
  })
);
friend.command("aliases").option("-j, --json", "JSON output").description("List aliases").action(
  wrapAction(async (opts, command) => {
    const { api } = await requireApi(command);
    output(await api.getAliasList(), Boolean(opts.json));
  })
);
friend.command("block <userId>").description("Block user").action(
  wrapAction(async (userId, command) => {
    const { api } = await requireApi(command);
    output(await api.blockUser(userId), false);
  })
);
friend.command("unblock <userId>").description("Unblock user").action(
  wrapAction(async (userId, command) => {
    const { api } = await requireApi(command);
    output(await api.unblockUser(userId), false);
  })
);
friend.command("block-feed <userId>").description("Block viewing your feed").action(
  wrapAction(async (userId, command) => {
    const { api } = await requireApi(command);
    output(await api.blockViewFeed(true, userId), false);
  })
);
friend.command("unblock-feed <userId>").description("Unblock viewing your feed").action(
  wrapAction(async (userId, command) => {
    const { api } = await requireApi(command);
    output(await api.blockViewFeed(false, userId), false);
  })
);
friend.command("boards <conversationId>").option("-j, --json", "JSON output").description("Get boards in conversation").action(
  wrapAction(async (conversationId, opts, command) => {
    const { api } = await requireApi(command);
    output(await api.getFriendBoardList(conversationId), Boolean(opts.json));
  })
);
var me = program.command("me").description("Profile/account commands");
me.command("info").option("-j, --json", "JSON output").description("Get account info").action(
  wrapAction(async (opts, command) => {
    const { api } = await requireApi(command);
    const outputValue = normalizeMeInfoOutput(await api.fetchAccountInfo());
    if (opts.json) {
      output(outputValue, true);
      return;
    }
    output(outputValue, false);
  })
);
me.command("id").description("Get own user ID").action(
  wrapAction(async (command) => {
    const { api } = await requireApi(command);
    console.log(api.getOwnId());
  })
);
me.command("update").option("--name <name>", "Display name").option("--gender <gender>", "male|female").option("--birthday <date>", "YYYY-MM-DD").description("Update profile").action(
  wrapAction(
    async (opts, command) => {
      if (!opts.name && !opts.gender && !opts.birthday) {
        throw new Error("Provide at least one of --name, --gender, --birthday");
      }
      const { api } = await requireApi(command);
      const currentInfo = normalizeAccountInfo(await api.fetchAccountInfo());
      const current = currentInfo.profile;
      const currentSdob = String(current.sdob ?? "");
      const currentDob = Number(current.dob ?? 0);
      let dob = opts.birthday;
      if (!dob) {
        if (/^\d{4}-\d{2}-\d{2}$/.test(currentSdob)) {
          dob = currentSdob;
        } else if (currentDob && Number.isFinite(currentDob)) {
          const ms = currentDob > 1e10 ? currentDob : currentDob * 1e3;
          dob = formatDateOnly(new Date(ms));
        } else {
          dob = "1970-01-01";
        }
      }
      let gender = Number(current.gender) === Gender.Female ? Gender.Female : Gender.Male;
      if (opts.gender) {
        const normalized = opts.gender.trim().toLowerCase();
        if (normalized === "male") gender = Gender.Male;
        else if (normalized === "female") gender = Gender.Female;
        else throw new Error('Gender must be "male" or "female"');
      }
      const name = opts.name ?? String(
        current.displayName ?? current.zaloName ?? current.username ?? currentInfo.displayName
      );
      const response = await api.updateProfile({
        profile: {
          name,
          dob,
          gender
        }
      });
      output(response, false);
    }
  )
);
me.command("avatar <file>").description("Change profile avatar").action(
  wrapAction(async (file, command) => {
    const { api } = await requireApi(command);
    const normalizedFile = normalizeMediaInput(file);
    await assertFilesExist([normalizedFile]);
    output(await api.changeAccountAvatar(normalizedFile), false);
  })
);
me.command("avatars").option("-j, --json", "JSON output").description("List avatars").action(
  wrapAction(async (opts, command) => {
    const { api } = await requireApi(command);
    output(await api.getAvatarList(), Boolean(opts.json));
  })
);
me.command("delete-avatar <id>").description("Delete avatar").action(
  wrapAction(async (id, command) => {
    const { api } = await requireApi(command);
    output(await api.deleteAvatar(id), false);
  })
);
me.command("reuse-avatar <id>").description("Reuse previous avatar").action(
  wrapAction(async (id, command) => {
    const { api } = await requireApi(command);
    output(await api.reuseAvatar(id), false);
  })
);
me.command("status <status>").description("Set online status (online|offline)").action(
  wrapAction(async (status, command) => {
    const normalized = status.trim().toLowerCase();
    if (!["online", "offline"].includes(normalized)) {
      throw new Error('Status must be "online" or "offline"');
    }
    const { api } = await requireApi(command);
    output(await api.updateActiveStatus(normalized === "online"), false);
  })
);
me.command("last-online <userId>").description("Get last online of a user").action(
  wrapAction(async (userId, command) => {
    const { api } = await requireApi(command);
    output(await api.lastOnline(userId), false);
  })
);
program.command("listen").description("Listen for real-time incoming messages").option("-e, --echo", "Echo incoming text message").option("-p, --prefix <prefix>", "Only process text starting with prefix").option("-w, --webhook <url>", "POST message payload to webhook").option("-r, --raw", "Output JSON line payload").option("--self", "Include events produced by the logged-in account").option("--db", "Force DB persistence for this listener session").option("--no-db", "Disable DB persistence for this listener session").option("-k, --keep-alive", "Auto restart listener on disconnect").option(
  "--supervised",
  "Supervisor mode (disable internal retry ownership; emit lifecycle events in --raw)"
).option(
  "--heartbeat-ms <ms>",
  "Lifecycle heartbeat interval in --supervised mode (default: 30000, 0 disables)"
).option(
  "--recycle-ms <ms>",
  "Force recycle listener after N ms (or use OPENZCA_LISTEN_RECYCLE_MS)"
).action(
  wrapAction(
    async (opts, command) => {
      const selfListen = Boolean(opts.self);
      const { profile, api } = await requireApi(command, { selfListen });
      const supervised = Boolean(opts.supervised);
      const defaultRecycleMs = 30 * 60 * 1e3;
      const recycleMs = parseNonNegativeIntOption("--recycle-ms", opts.recycleMs) ?? parseNonNegativeIntOption(
        "OPENZCA_LISTEN_RECYCLE_MS",
        process.env.OPENZCA_LISTEN_RECYCLE_MS
      ) ?? defaultRecycleMs;
      const heartbeatMs = parseNonNegativeIntOption("--heartbeat-ms", opts.heartbeatMs) ?? parseNonNegativeIntOption(
        "OPENZCA_LISTEN_HEARTBEAT_MS",
        process.env.OPENZCA_LISTEN_HEARTBEAT_MS
      ) ?? 3e4;
      const lifecycleEventsEnabled = supervised && Boolean(opts.raw);
      const recycleEnabled = !supervised && Boolean(opts.keepAlive) && recycleMs > 0;
      const keepAliveRestartDelayMs = parsePositiveIntFromEnv(
        "OPENZCA_LISTEN_KEEPALIVE_RESTART_DELAY_MS",
        2e3
      );
      const keepAliveRestartOnAnyClose = parseBooleanFromEnv(
        "OPENZCA_LISTEN_KEEPALIVE_RESTART_ON_ANY_CLOSE",
        false
      );
      const recycleExitCode = 75;
      const includeReplyContext = parseToggleDefaultTrue(
        process.env.OPENZCA_LISTEN_INCLUDE_QUOTE_CONTEXT
      );
      const downloadQuoteMedia = parseToggleDefaultTrue(
        process.env.OPENZCA_LISTEN_DOWNLOAD_QUOTE_MEDIA
      );
      const sessionId = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 10)}`;
      const selfId = api.getOwnId();
      const dbWriteEnabled = await shouldWriteToDb(profile, getDbWriteOverride(opts));
      const emitLifecycle = (event, fields) => {
        if (!lifecycleEventsEnabled) return;
        console.log(
          JSON.stringify({
            kind: "lifecycle",
            event,
            session_id: sessionId,
            profile,
            timestamp: Math.floor(Date.now() / 1e3),
            ...fields
          })
        );
      };
      const enforceSingleOwner = parseBooleanFromEnv("OPENZCA_LISTEN_ENFORCE_SINGLE_OWNER", true);
      let ownerLock = null;
      let ipcServer = null;
      let ipcSocketPath;
      let resourcesCleaned = false;
      const cleanupListenResources = async () => {
        if (resourcesCleaned) return;
        resourcesCleaned = true;
        if (ipcServer) {
          await ipcServer.close();
          ipcServer = null;
        }
        if (ownerLock) {
          await ownerLock.release();
          ownerLock = null;
        }
      };
      const unregisterResourceCleanup = registerShutdownCallback(async () => {
        await cleanupListenResources();
      });
      try {
        if (enforceSingleOwner) {
          ownerLock = await acquireListenerOwnerLock(profile, sessionId, command);
          writeDebugLine(
            "listen.owner.acquired",
            {
              profile,
              lockPath: ownerLock.lockPath,
              pid: process.pid,
              sessionId
            },
            command
          );
        }
        ipcServer = await startListenerIpcServer(api, profile, sessionId, command);
        ipcSocketPath = ipcServer?.socketPath;
        console.log("Listening... Press Ctrl+C to stop.");
        if (supervised && opts.keepAlive) {
          console.error("Warning: --supervised ignores internal --keep-alive reconnect ownership.");
        }
        writeDebugLine(
          "listen.start",
          {
            profile,
            mediaDir: resolveInboundMediaDir(profile),
            maxMediaBytes: parseMaxInboundMediaBytes(),
            maxMediaFiles: parseMaxInboundMediaFiles(),
            includeMediaUrl: process.env.OPENZCA_LISTEN_INCLUDE_MEDIA_URL?.trim() ?? null,
            keepAlive: Boolean(opts.keepAlive),
            selfListen,
            keepAliveRestartDelayMs: Boolean(opts.keepAlive) ? keepAliveRestartDelayMs : void 0,
            keepAliveRestartOnAnyClose: Boolean(opts.keepAlive) ? keepAliveRestartOnAnyClose : void 0,
            supervised,
            lifecycleEventsEnabled,
            heartbeatMs: lifecycleEventsEnabled ? heartbeatMs : void 0,
            recycleMs: recycleEnabled ? recycleMs : void 0,
            includeReplyContext,
            downloadQuoteMedia,
            sessionId,
            singleOwner: enforceSingleOwner,
            ipcSocketPath
          },
          command
        );
        emitLifecycle("session_id");
        let keepAliveRestartTimer = null;
        async function emitWebhook(payload) {
          if (!opts.webhook) return;
          try {
            const response = await fetch(opts.webhook, {
              method: "POST",
              headers: {
                "content-type": "application/json"
              },
              body: JSON.stringify(payload)
            });
            if (!response.ok) {
              console.error(`Webhook response: ${response.status}`);
            }
          } catch (error) {
            console.error(
              `Webhook failed: ${error instanceof Error ? error.message : String(error)}`
            );
          }
        }
        api.listener.on("connected", () => {
          console.log("Connected to Zalo websocket.");
          if (keepAliveRestartTimer) {
            clearTimeout(keepAliveRestartTimer);
            keepAliveRestartTimer = null;
          }
          writeDebugLine(
            "listen.connected",
            {
              profile,
              sessionId
            },
            command
          );
          emitLifecycle("connected");
        });
        api.listener.on("message", async (message) => {
          const messageData = message.data;
          const rawContent = messageData.content;
          const msgType = getStringCandidate(messageData, ["msgType"]);
          let quote = normalizeQuoteContext(messageData.quote);
          const parsedContent = normalizeStructuredContent(rawContent);
          const hasParsedStructuredContent = parsedContent !== rawContent;
          const rawText = typeof rawContent === "string" ? rawContent : "";
          const mediaKind = detectInboundMediaKind(msgType, parsedContent);
          const maxMediaFiles = parseMaxInboundMediaFiles();
          const remoteMediaUrls = mediaKind && maxMediaFiles > 0 ? resolvePreferredMediaUrls(mediaKind, parsedContent).slice(0, maxMediaFiles) : [];
          const quoteRemoteMediaUrls = quote && downloadQuoteMedia && maxMediaFiles > 0 ? (quote.mediaUrls ?? []).slice(0, maxMediaFiles) : [];
          writeDebugLine(
            "listen.media.detected",
            {
              profile,
              threadId: message.threadId,
              msgType: msgType || void 0,
              mediaKind,
              hasParsedStructuredContent,
              remoteMediaUrls,
              hasQuote: Boolean(quote),
              quoteOwnerId: quote?.ownerId,
              quoteGlobalMsgId: quote?.globalMsgId,
              quoteCliMsgId: quote?.cliMsgId,
              quoteRemoteMediaUrls
            },
            command
          );
          const [mediaEntries, quoteMediaEntries] = await Promise.all([
            mediaKind ? cacheRemoteMediaEntries({
              profile,
              urls: remoteMediaUrls,
              kind: mediaKind,
              command,
              warningLabel: "inbound media",
              debugErrorEvent: "listen.media.cache_error",
              debugUrlKey: "mediaUrl"
            }) : Promise.resolve([]),
            cacheRemoteMediaEntries({
              profile,
              urls: quoteRemoteMediaUrls,
              kind: "file",
              command,
              warningLabel: "quoted media",
              debugErrorEvent: "listen.quote_media.cache_error",
              debugUrlKey: "quoteMediaUrl"
            })
          ]);
          const localEntries = mediaEntries.filter((entry) => Boolean(entry.mediaPath));
          const mediaPaths = localEntries.map((entry) => entry.mediaPath);
          const mediaUrls = localEntries.length > 0 ? localEntries.map((entry) => entry.mediaUrl).filter((value) => Boolean(value)) : mediaEntries.map((entry) => entry.mediaUrl).filter((value) => Boolean(value));
          const mediaTypes = localEntries.length > 0 ? localEntries.map((entry) => entry.mediaType).filter((value) => Boolean(value)) : mediaEntries.map((entry) => entry.mediaType).filter((value) => Boolean(value));
          const mediaPath = mediaPaths[0];
          const mediaUrl = mediaUrls[0];
          const mediaType = mediaTypes[0];
          const quoteLocalEntries = quoteMediaEntries.filter((entry) => Boolean(entry.mediaPath));
          const quoteMediaPaths = quoteLocalEntries.map((entry) => entry.mediaPath);
          const quoteMediaUrls = quoteLocalEntries.length > 0 ? quoteLocalEntries.map((entry) => entry.mediaUrl).filter((value) => Boolean(value)) : quoteMediaEntries.map((entry) => entry.mediaUrl).filter((value) => Boolean(value));
          const quoteMediaTypes = quoteLocalEntries.length > 0 ? quoteLocalEntries.map((entry) => entry.mediaType).filter((value) => Boolean(value)) : quoteMediaEntries.map((entry) => entry.mediaType).filter((value) => Boolean(value));
          const quoteMediaPath = quoteMediaPaths[0];
          const quoteMediaUrl = quoteMediaUrls[0];
          const quoteMediaType = quoteMediaTypes[0];
          if (quote) {
            quote = {
              ...quote,
              mediaPath: quoteMediaPath,
              mediaPaths: quoteMediaPaths.length > 0 ? quoteMediaPaths : void 0,
              mediaUrl: quoteMediaUrl,
              mediaUrls: quoteMediaUrls.length > 0 ? quoteMediaUrls : quote.mediaUrls,
              mediaType: quoteMediaType,
              mediaTypes: quoteMediaTypes.length > 0 ? quoteMediaTypes : void 0
            };
          }
          const replyContextText = includeReplyContext && quote ? buildReplyContextText(quote) : "";
          const replyMediaText = includeReplyContext && quoteMediaEntries.length > 0 ? buildReplyMediaAttachedText({ mediaEntries: quoteMediaEntries }) : "";
          const caption = rawText.trim().length > 0 && !hasParsedStructuredContent ? rawText.trim() : summarizeStructuredContent(msgType, parsedContent);
          let processedText = mediaEntries.length ? buildMediaAttachedText({
            mediaEntries,
            fallbackKind: mediaKind,
            caption
          }) : rawText.trim().length > 0 && !hasParsedStructuredContent ? rawText : summarizeStructuredContent(msgType, parsedContent);
          if (!processedText.trim() && !replyContextText && !replyMediaText) return;
          if (opts.prefix && processedText.trim().length > 0) {
            if (!processedText.startsWith(opts.prefix)) return;
            processedText = processedText.slice(opts.prefix.length).trimStart();
          }
          if (replyMediaText) {
            processedText = processedText.trim() ? `${processedText}
${replyMediaText}` : replyMediaText;
          }
          if (replyContextText) {
            processedText = processedText.trim() ? `${processedText}
${replyContextText}` : replyContextText;
          }
          const chatType = message.type === ThreadType3.Group ? "group" : "user";
          const senderId = getStringCandidate(messageData, ["uidFrom"]) || message.data.uidFrom;
          const senderDisplayNameRaw = getStringCandidate(messageData, [
            "dName",
            "fromD",
            "senderName",
            "displayName"
          ]);
          const senderDisplayName = senderDisplayNameRaw || void 0;
          const senderNameForMetadata = message.type === ThreadType3.Group ? senderDisplayName : void 0;
          const toId = getStringCandidate(messageData, ["idTo"]) || void 0;
          const threadName = typeof messageData.threadName === "string" ? messageData.threadName : typeof messageData.tName === "string" ? messageData.tName : void 0;
          const mentions = extractInboundMentions({
            messageData,
            parsedContent,
            rawText
          });
          const mentionIds = mentions.map((item) => item.uid);
          const poll = extractInboundPollInfo(messageData, parsedContent);
          const timestamp = toEpochSeconds(message.data.ts);
          const timestampMs = toEpochMs(message.data.ts);
          const payload = {
            threadId: message.threadId,
            targetId: message.threadId,
            conversationId: message.threadId,
            msgId: message.data.msgId,
            cliMsgId: message.data.cliMsgId,
            content: processedText,
            type: message.type,
            timestamp,
            msgType: msgType || void 0,
            quote: quote ?? void 0,
            quoteMediaPath,
            quoteMediaPaths: quoteMediaPaths.length > 0 ? quoteMediaPaths : void 0,
            quoteMediaUrl,
            quoteMediaUrls: quoteMediaUrls.length > 0 ? quoteMediaUrls : void 0,
            quoteMediaType,
            quoteMediaTypes: quoteMediaTypes.length > 0 ? quoteMediaTypes : void 0,
            mediaPath,
            mediaPaths: mediaPaths.length > 0 ? mediaPaths : void 0,
            mediaUrl,
            mediaUrls: mediaUrls.length > 0 ? mediaUrls : void 0,
            mediaType,
            mediaTypes: mediaTypes.length > 0 ? mediaTypes : void 0,
            mediaKind: mediaKind ?? void 0,
            mentions: mentions.length > 0 ? mentions : void 0,
            mentionIds: mentionIds.length > 0 ? mentionIds : void 0,
            poll: poll ?? void 0,
            pollId: poll?.pollId,
            pollTitle: poll?.title,
            pollOptionIds: poll?.optionIds,
            rawMessage: poll ? message.data : void 0,
            metadata: {
              isGroup: message.type === ThreadType3.Group,
              chatType,
              threadId: message.threadId,
              targetId: message.threadId,
              threadName,
              senderName: senderNameForMetadata,
              senderDisplayName,
              senderId,
              fromId: senderId,
              toId,
              msgType: msgType || void 0,
              quote: quote ?? void 0,
              quoteMediaPath,
              quoteMediaPaths: quoteMediaPaths.length > 0 ? quoteMediaPaths : void 0,
              quoteMediaUrl,
              quoteMediaUrls: quoteMediaUrls.length > 0 ? quoteMediaUrls : void 0,
              quoteMediaType,
              quoteMediaTypes: quoteMediaTypes.length > 0 ? quoteMediaTypes : void 0,
              timestamp,
              mediaPath,
              mediaPaths: mediaPaths.length > 0 ? mediaPaths : void 0,
              mediaUrl,
              mediaUrls: mediaUrls.length > 0 ? mediaUrls : void 0,
              mediaType,
              mediaTypes: mediaTypes.length > 0 ? mediaTypes : void 0,
              mediaKind: mediaKind ?? void 0,
              mentions: mentions.length > 0 ? mentions : void 0,
              mentionIds: mentionIds.length > 0 ? mentionIds : void 0,
              mentionCount: mentions.length > 0 ? mentions.length : void 0,
              poll: poll ?? void 0,
              pollId: poll?.pollId,
              pollTitle: poll?.title,
              pollOptionIds: poll?.optionIds,
              rawMessage: poll ? message.data : void 0
            },
            // Backward-compatible convenience fields.
            chatType,
            senderId,
            senderName: senderDisplayName,
            senderDisplayName,
            toId,
            ts: message.data.ts
          };
          if (dbWriteEnabled) {
            const mediaForDb = mediaEntries.map((entry) => ({
              mediaKind: mediaKind ?? void 0,
              mediaUrl: entry.mediaUrl,
              mediaPath: entry.mediaPath,
              mediaType: entry.mediaType,
              rawJson: JSON.stringify(entry)
            }));
            const mentionsForDb = mentions.map((mention) => ({
              uid: mention.uid,
              pos: mention.pos,
              len: mention.len,
              type: mention.type,
              rawJson: JSON.stringify(mention)
            }));
            scheduleDbWrite(profile, command, "listen.db.persist_error", async () => {
              const normalizedRecord = normalizeInboundListenRecord({
                profile,
                threadType: chatType,
                rawThreadId: message.threadId,
                senderId,
                senderName: senderDisplayName,
                toId,
                selfId,
                title: chatType === "group" ? threadName : senderDisplayName,
                msgId: message.data.msgId,
                cliMsgId: message.data.cliMsgId,
                actionId: getStringCandidate(messageData, ["actionId"]),
                timestampMs,
                msgType: msgType || void 0,
                contentText: processedText || rawText || void 0,
                contentJson: rawContent && typeof rawContent === "object" ? JSON.stringify(rawContent) : void 0,
                quoteMsgId: quote?.globalMsgId ? String(quote.globalMsgId) : void 0,
                quoteCliMsgId: quote?.cliMsgId ? String(quote.cliMsgId) : void 0,
                quoteOwnerId: quote?.ownerId ? String(quote.ownerId) : void 0,
                quoteText: quote?.msg,
                media: mediaForDb,
                mentions: mentionsForDb,
                rawMessage: message.data,
                rawPayload: payload,
                source: "listen"
              });
              if (chatType === "group") {
                await hydrateUnknownLiveGroup({
                  profile,
                  api,
                  groupId: normalizedRecord.scopeThreadId,
                  fallbackTitle: threadName
                });
              } else {
                await persistLiveDmContact({
                  profile,
                  api,
                  peerId: normalizedRecord.scopeThreadId,
                  senderDisplayName,
                  senderName: senderDisplayName,
                  timestampMs,
                  rawJson: JSON.stringify({
                    userId: normalizedRecord.scopeThreadId,
                    displayName: senderDisplayName
                  })
                });
              }
              await persistMessage(normalizedRecord);
            });
          }
          if (opts.raw) {
            console.log(JSON.stringify(payload));
          } else {
            console.log(
              `[${chatType}] ${payload.senderName || payload.senderId} -> ${payload.threadId}: ${payload.content}`
            );
          }
          await emitWebhook(payload);
          if (opts.echo && rawText.trim().length > 0) {
            const sendMessage = retrySendMethod(
              api.sendMessage.bind(api),
              command,
              (_sendPayload, targetThreadId, targetThreadType) => ({
                kind: "listen.echo",
                threadId: targetThreadId,
                threadType: targetThreadType === ThreadType3.Group ? "group" : "user"
              })
            );
            try {
              await sendMessage({ msg: processedText }, message.threadId, message.type);
            } catch (error) {
              console.error(
                `Echo failed: ${error instanceof Error ? error.message : String(error)}`
              );
            }
          }
        });
        api.listener.on("group_event", async (event) => {
          const poll = extractInboundPollInfo(event);
          if (!poll) return;
          const eventData = asObject(event.data);
          const groupTopic = asObject(eventData?.groupTopic);
          const actorId = getStringCandidate(eventData ?? {}, ["sourceId", "creatorId", "actorId", "editorId"]) || getStringCandidate(groupTopic ?? {}, ["creatorId", "editorId"]);
          const threadName = getStringCandidate(eventData ?? {}, ["groupName"]);
          const timestampSource = eventData?.time ?? groupTopic?.createTime ?? groupTopic?.editTime ?? Date.now();
          const timestamp = toEpochSeconds(timestampSource);
          const payload = {
            kind: "group_event",
            event: "poll",
            threadId: event.threadId,
            targetId: event.threadId,
            conversationId: event.threadId,
            type: ThreadType3.Group,
            timestamp,
            groupEventType: event.type,
            act: event.act,
            poll,
            pollId: poll.pollId,
            pollTitle: poll.title,
            pollOptionIds: poll.optionIds,
            rawGroupEvent: event,
            metadata: {
              isGroup: true,
              chatType: "group",
              threadId: event.threadId,
              targetId: event.threadId,
              threadName: threadName || void 0,
              senderId: actorId || void 0,
              fromId: actorId || void 0,
              timestamp,
              groupEventType: event.type,
              act: event.act,
              poll,
              pollId: poll.pollId,
              pollTitle: poll.title,
              pollOptionIds: poll.optionIds,
              rawGroupEvent: event
            },
            chatType: "group",
            senderId: actorId || void 0,
            senderName: void 0,
            senderDisplayName: void 0
          };
          writeDebugLine(
            "listen.group_event.poll",
            {
              profile,
              threadId: event.threadId,
              groupEventType: event.type,
              act: event.act,
              pollId: poll.pollId,
              pollTitle: poll.title,
              sessionId
            },
            command
          );
          if (opts.raw) {
            console.log(JSON.stringify(payload));
          }
        });
        api.listener.on("error", (error) => {
          writeDebugLine(
            "listen.error",
            {
              profile,
              message: error instanceof Error ? error.message : String(error),
              sessionId
            },
            command
          );
          emitLifecycle("error", {
            message: error instanceof Error ? error.message : String(error)
          });
          console.error(
            `Listener error: ${error instanceof Error ? error.message : String(error)}`
          );
        });
        await new Promise((resolve) => {
          let settled = false;
          let recycleTimer = null;
          let recycleForceExitTimer = null;
          let heartbeatTimer = null;
          let recyclePendingExit = false;
          let unregisterShutdown = () => {
          };
          const finish = () => {
            if (settled) return;
            settled = true;
            if (recycleTimer) {
              clearTimeout(recycleTimer);
              recycleTimer = null;
            }
            if (recycleForceExitTimer && !recyclePendingExit) {
              clearTimeout(recycleForceExitTimer);
              recycleForceExitTimer = null;
            }
            if (heartbeatTimer) {
              clearInterval(heartbeatTimer);
              heartbeatTimer = null;
            }
            if (keepAliveRestartTimer) {
              clearTimeout(keepAliveRestartTimer);
              keepAliveRestartTimer = null;
            }
            unregisterShutdown();
            unregisterShutdown = () => {
            };
            resolve();
          };
          api.listener.on("closed", (code, reason) => {
            console.log(`Listener closed (${code}) ${reason || ""}`);
            writeDebugLine(
              "listen.closed",
              {
                profile,
                code,
                reason: reason || void 0,
                sessionId
              },
              command
            );
            emitLifecycle("closed", {
              code,
              reason: reason || void 0
            });
            if (!opts.keepAlive || supervised) {
              finish();
              return;
            }
            const shouldRestart = keepAliveRestartOnAnyClose || code === 1e3 || code === 3e3;
            if (!shouldRestart) return;
            if (keepAliveRestartTimer) {
              clearTimeout(keepAliveRestartTimer);
            }
            keepAliveRestartTimer = setTimeout(() => {
              keepAliveRestartTimer = null;
              writeDebugLine(
                "listen.keepalive.restart",
                {
                  profile,
                  code,
                  reason: reason || void 0,
                  delayMs: keepAliveRestartDelayMs,
                  sessionId
                },
                command
              );
              try {
                api.listener.start({ retryOnClose: true });
              } catch (error) {
                if (!isListenerAlreadyStarted(error)) {
                  writeDebugLine(
                    "listen.keepalive.restart_error",
                    {
                      profile,
                      code,
                      reason: reason || void 0,
                      delayMs: keepAliveRestartDelayMs,
                      message: toErrorText2(error),
                      sessionId
                    },
                    command
                  );
                }
              }
            }, keepAliveRestartDelayMs);
          });
          const onSignal = () => {
            try {
              api.listener.stop();
            } catch {
            }
            finish();
          };
          unregisterShutdown = registerShutdownCallback(onSignal);
          if (lifecycleEventsEnabled && heartbeatMs > 0) {
            heartbeatTimer = setInterval(() => {
              emitLifecycle("heartbeat");
            }, heartbeatMs);
          }
          if (recycleEnabled) {
            recycleTimer = setTimeout(() => {
              console.error(
                `Listener recycle triggered after ${recycleMs}ms to prevent stale session.`
              );
              writeDebugLine(
                "listen.recycle",
                {
                  profile,
                  recycleMs,
                  exitCode: recycleExitCode,
                  sessionId
                },
                command
              );
              process.exitCode = recycleExitCode;
              recyclePendingExit = true;
              recycleForceExitTimer = setTimeout(() => {
                recycleForceExitTimer = null;
                process.exit(recycleExitCode);
              }, 3e3);
              recycleForceExitTimer.unref();
              try {
                api.listener.stop();
              } catch {
              }
              finish();
            }, recycleMs);
          }
          api.listener.start({ retryOnClose: supervised ? false : Boolean(opts.keepAlive) });
        });
      } finally {
        unregisterResourceCleanup();
        await cleanupListenResources();
      }
    }
  )
);
program.parseAsync(normalizeCommandAliases(process.argv));
