GPT Proto
Home/Skills/claude-to-im

claude-to-im

Bridge THIS Claude Code or Codex session to Telegram, Discord, Feishu/Lark, QQ, or WeChat so the

Download for Windows

weixin-store.ts

import fs from 'node:fs';
import path from 'node:path';
import { CTI_HOME } from './config.js';

export interface WeixinAccountRecord {
  accountId: string;
  userId: string;
  baseUrl: string;
  cdnBaseUrl: string;
  token: string;
  name: string;
  enabled: boolean;
  lastLoginAt: string | null;
  createdAt: string;
  updatedAt: string;
}

const DATA_DIR = path.join(CTI_HOME, 'data');
// Keep the historical plural filename for compatibility, even though the
// current Weixin bridge intentionally runs in single-account mode.
const ACCOUNTS_PATH = path.join(DATA_DIR, 'weixin-accounts.json');
const CONTEXT_TOKENS_PATH = path.join(DATA_DIR, 'weixin-context-tokens.json');
const DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com';
const DEFAULT_CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c';

function ensureDir(dir: string): void {
  fs.mkdirSync(dir, { recursive: true });
}

function atomicWrite(filePath: string, data: string): void {
  const tmpPath = `${filePath}.tmp`;
  fs.writeFileSync(tmpPath, data, 'utf-8');
  fs.renameSync(tmpPath, filePath);
}

function readJson<T>(filePath: string, fallback: T): T {
  try {
    const raw = fs.readFileSync(filePath, 'utf-8');
    return JSON.parse(raw) as T;
  } catch {
    return fallback;
  }
}

function now(): string {
  return new Date().toISOString();
}

function getAccountRecency(account: WeixinAccountRecord): string {
  return account.lastLoginAt ?? account.updatedAt ?? account.createdAt;
}

function normalizeAccounts(accounts: WeixinAccountRecord[]): {
  accounts: WeixinAccountRecord[];
  removedAccountIds: string[];
} {
  // Single-account mode: the newest linked account wins and older records
  // are treated as replaceable history.
  if (accounts.length <= 1) {
    return { accounts, removedAccountIds: [] };
  }

  const sorted = [...accounts].sort((a, b) => {
    const recencyDiff = getAccountRecency(b).localeCompare(getAccountRecency(a));
    if (recencyDiff !== 0) return recencyDiff;

    const updatedDiff = b.updatedAt.localeCompare(a.updatedAt);
    if (updatedDiff !== 0) return updatedDiff;

    const createdDiff = b.createdAt.localeCompare(a.createdAt);
    if (createdDiff !== 0) return createdDiff;

    return 0;
  });

  const kept = sorted[0];
  const removedAccountIds = [
    ...new Set(
      sorted
        .slice(1)
        .map((account) => account.accountId)
        .filter((accountId) => accountId !== kept.accountId),
    ),
  ];

  return {
    accounts: [kept],
    removedAccountIds,
  };
}

function persistAccounts(accounts: WeixinAccountRecord[]): WeixinAccountRecord[] {
  ensureDir(DATA_DIR);
  const normalized = normalizeAccounts(accounts);
  atomicWrite(ACCOUNTS_PATH, JSON.stringify(normalized.accounts, null, 2));
  for (const accountId of normalized.removedAccountIds) {
    deleteWeixinContextTokensByAccount(accountId);
  }
  return normalized.accounts;
}

function readStoredAccounts(): WeixinAccountRecord[] {
  ensureDir(DATA_DIR);
  const raw = readJson<unknown>(ACCOUNTS_PATH, []);
  return Array.isArray(raw) ? raw as WeixinAccountRecord[] : [];
}

function readAccounts(): WeixinAccountRecord[] {
  return normalizeAccounts(readStoredAccounts()).accounts;
}

function writeAccounts(accounts: WeixinAccountRecord[]): void {
  persistAccounts(accounts);
}

function readContextTokens(): Record<string, string> {
  ensureDir(DATA_DIR);
  return readJson<Record<string, string>>(CONTEXT_TOKENS_PATH, {});
}

function writeContextTokens(tokens: Record<string, string>): void {
  ensureDir(DATA_DIR);
  atomicWrite(CONTEXT_TOKENS_PATH, JSON.stringify(tokens, null, 2));
}

function contextKey(accountId: string, peerUserId: string): string {
  return `${accountId}::${peerUserId}`;
}

export function listWeixinAccounts(): WeixinAccountRecord[] {
  return readAccounts().sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}

export function getWeixinAccount(accountId: string): WeixinAccountRecord | undefined {
  return readAccounts().find((account) => account.accountId === accountId);
}

export function upsertWeixinAccount(params: {
  accountId: string;
  userId?: string;
  baseUrl?: string;
  cdnBaseUrl?: string;
  token?: string;
  name?: string;
  enabled?: boolean;
}): WeixinAccountRecord {
  const accounts = readAccounts();
  const existing = accounts.find((account) => account.accountId === params.accountId);
  const timestamp = now();

  const account: WeixinAccountRecord = existing
    ? {
        ...existing,
        userId: params.userId ?? existing.userId,
        baseUrl: params.baseUrl ?? existing.baseUrl,
        cdnBaseUrl: params.cdnBaseUrl ?? existing.cdnBaseUrl,
        token: params.token ?? existing.token,
        name: params.name ?? existing.name,
        enabled: params.enabled ?? existing.enabled,
        lastLoginAt: timestamp,
        updatedAt: timestamp,
      }
    : {
        accountId: params.accountId,
        userId: params.userId ?? '',
        baseUrl: params.baseUrl ?? DEFAULT_BASE_URL,
        cdnBaseUrl: params.cdnBaseUrl ?? DEFAULT_CDN_BASE_URL,
        token: params.token ?? '',
        name: params.name ?? params.accountId,
        enabled: params.enabled ?? true,
        lastLoginAt: timestamp,
        createdAt: timestamp,
        updatedAt: timestamp,
      };

  const nextAccounts = [
    account,
    ...accounts.filter((item) => item.accountId !== account.accountId),
  ];
  writeAccounts(nextAccounts);
  return account;
}

export function deleteWeixinAccount(accountId: string): boolean {
  const accounts = readAccounts();
  const nextAccounts = accounts.filter((account) => account.accountId !== accountId);
  if (nextAccounts.length === accounts.length) {
    return false;
  }
  writeAccounts(nextAccounts);
  deleteWeixinContextTokensByAccount(accountId);
  return true;
}

export function setWeixinAccountEnabled(accountId: string, enabled: boolean): boolean {
  const accounts = readAccounts();
  let changed = false;
  const nextAccounts = accounts.map((account) => {
    if (account.accountId !== accountId) return account;
    changed = true;
    return {
      ...account,
      enabled,
      updatedAt: now(),
    };
  });

  if (changed) {
    writeAccounts(nextAccounts);
  }
  return changed;
}

export function getWeixinContextToken(accountId: string, peerUserId: string): string | undefined {
  const tokens = readContextTokens();
  return tokens[contextKey(accountId, peerUserId)];
}

export function upsertWeixinContextToken(accountId: string, peerUserId: string, contextToken: string): void {
  const tokens = readContextTokens();
  tokens[contextKey(accountId, peerUserId)] = contextToken;
  writeContextTokens(tokens);
}

export function deleteWeixinContextTokensByAccount(accountId: string): void {
  const tokens = readContextTokens();
  const nextTokens = Object.fromEntries(
    Object.entries(tokens).filter(([key]) => !key.startsWith(`${accountId}::`)),
  );
  writeContextTokens(nextTokens);
}

export function getWeixinAccountsFilePath(): string {
  return ACCOUNTS_PATH;
}