feat: publish workphone SDK deployment and API docs
This commit is contained in:
549
sdk/agent/hook/wechat_hook_ckb_patch.js
Normal file
549
sdk/agent/hook/wechat_hook_ckb_patch.js
Normal file
@@ -0,0 +1,549 @@
|
||||
/**
|
||||
* wechat_hook_ckb_patch.js — 存客宝专项 Hook 扩展补丁
|
||||
*
|
||||
* 在 wechat_hook_v2.js 基础上,新增以下功能(F-CKB-1 ~ F-CKB-3):
|
||||
*
|
||||
* F-CKB-1 getContactsDetail — 获取联系人详细信息(标签/手机号/性别/地区/备注)
|
||||
* F-CKB-2 getGroupMembersDetail — 获取群成员详细信息(含每个成员的联系人信息)
|
||||
* F-CKB-3 onFriendAdd — 新好友添加事件监听(实时上报到 SDK 服务端)
|
||||
* F-CKB-3 onGroupMemberJoin — 群成员加入事件监听(实时上报到 SDK 服务端)
|
||||
* F-CKB-4 batchSyncContacts — 批量同步通讯录到存客宝(含分批上报)
|
||||
*
|
||||
* 使用方式:
|
||||
* 此文件作为独立补丁脚本,与 wechat_hook_v2.js 一起注入,
|
||||
* 或将以下函数合并到 wechat_hook_v2.js 的对应位置。
|
||||
*
|
||||
* @version 1.0.0 (CKB专项)
|
||||
* @date 2026-05-15
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
// ============================================================
|
||||
// § CKB-1 联系人详细信息获取(扩展版)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 获取联系人详细信息(包含存客宝所需的全部字段)
|
||||
*
|
||||
* 数据来源:
|
||||
* - rcontact 表:基础信息(wxid/nickname/remark/type/alias)
|
||||
* - contactlabelinfo 表:标签信息
|
||||
* - bizinfo 表:手机号(部分版本)
|
||||
*
|
||||
* @param {number} limit - 最大返回数量,默认 500
|
||||
* @returns {{ success: boolean, contacts: Array, count: number }}
|
||||
*/
|
||||
function _getContactsDetailFromDB(limit) {
|
||||
limit = limit || 500;
|
||||
|
||||
// Step 1: 获取基础联系人信息(扩展字段)
|
||||
var sql = "SELECT username, nickname, conRemark, type, alias, "
|
||||
+ "imgFlag, sex, country, province, city, signature "
|
||||
+ "FROM rcontact "
|
||||
+ "WHERE type NOT IN (0, 33) AND verifyFlag=0 "
|
||||
+ "AND username NOT LIKE '%@chatroom' "
|
||||
+ "AND username NOT LIKE 'gh_%' "
|
||||
+ "ORDER BY nickname LIMIT " + limit;
|
||||
|
||||
var rows = _execSQL(sql);
|
||||
|
||||
// Step 2: 获取标签映射
|
||||
var labelMap = {};
|
||||
try {
|
||||
var labelSql = "SELECT labelId, labelName FROM contactlabelinfo";
|
||||
var labelRows = _execSQL(labelSql);
|
||||
labelRows.forEach(function(r) {
|
||||
labelMap[r.labelId] = r.labelName;
|
||||
});
|
||||
} catch(e) {
|
||||
// 部分版本无此表,忽略
|
||||
}
|
||||
|
||||
// Step 3: 获取联系人标签关联
|
||||
var contactLabelMap = {};
|
||||
try {
|
||||
var clSql = "SELECT username, labelidlist FROM rcontact WHERE labelidlist IS NOT NULL AND labelidlist != ''";
|
||||
var clRows = _execSQL(clSql);
|
||||
clRows.forEach(function(r) {
|
||||
if (r.labelidlist) {
|
||||
var ids = r.labelidlist.split(',').filter(function(id) { return id; });
|
||||
var names = ids.map(function(id) { return labelMap[id] || id; }).filter(function(n) { return n; });
|
||||
contactLabelMap[r.username] = names;
|
||||
}
|
||||
});
|
||||
} catch(e) {
|
||||
// 忽略
|
||||
}
|
||||
|
||||
// Step 4: 组装详细联系人信息
|
||||
var contacts = rows.map(function(r) {
|
||||
var sexMap = { '0': 'unknown', '1': 'male', '2': 'female' };
|
||||
return {
|
||||
wxid: r.username || '',
|
||||
nickname: r.nickname || '',
|
||||
remark: r.conRemark || '',
|
||||
alias: r.alias || '', // 微信号(字母ID)
|
||||
type: r.type || 0,
|
||||
sex: sexMap[String(r.sex)] || 'unknown',
|
||||
country: r.country || '',
|
||||
province: r.province || '',
|
||||
city: r.city || '',
|
||||
signature: r.signature || '', // 个性签名
|
||||
tags: contactLabelMap[r.username] || [], // 标签列表
|
||||
has_avatar: (r.imgFlag || 0) > 0,
|
||||
};
|
||||
});
|
||||
|
||||
return { success: true, contacts: contacts, count: contacts.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个联系人的详细信息
|
||||
*
|
||||
* @param {string} wxid - 微信号
|
||||
* @returns {{ success: boolean, contact: Object }}
|
||||
*/
|
||||
function _getContactDetailFromDB(wxid) {
|
||||
if (!wxid) return { success: false, error: '缺少 wxid' };
|
||||
|
||||
var sql = "SELECT username, nickname, conRemark, type, alias, "
|
||||
+ "imgFlag, sex, country, province, city, signature "
|
||||
+ "FROM rcontact WHERE username='" + wxid.replace(/'/g, "''") + "'";
|
||||
|
||||
var rows = _execSQL(sql);
|
||||
if (rows.length === 0) return { success: false, error: '未找到联系人: ' + wxid };
|
||||
|
||||
var r = rows[0];
|
||||
|
||||
// 获取标签
|
||||
var tags = [];
|
||||
try {
|
||||
var labelSql = "SELECT labelidlist FROM rcontact WHERE username='" + wxid.replace(/'/g, "''") + "'";
|
||||
var lRows = _execSQL(labelSql);
|
||||
if (lRows.length > 0 && lRows[0].labelidlist) {
|
||||
var labelMapSql = "SELECT labelId, labelName FROM contactlabelinfo";
|
||||
var lmRows = _execSQL(labelMapSql);
|
||||
var lm = {};
|
||||
lmRows.forEach(function(lr) { lm[lr.labelId] = lr.labelName; });
|
||||
tags = lRows[0].labelidlist.split(',')
|
||||
.filter(function(id) { return id; })
|
||||
.map(function(id) { return lm[id] || id; });
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
var sexMap = { '0': 'unknown', '1': 'male', '2': 'female' };
|
||||
return {
|
||||
success: true,
|
||||
contact: {
|
||||
wxid: r.username || '',
|
||||
nickname: r.nickname || '',
|
||||
remark: r.conRemark || '',
|
||||
alias: r.alias || '',
|
||||
type: r.type || 0,
|
||||
sex: sexMap[String(r.sex)] || 'unknown',
|
||||
country: r.country || '',
|
||||
province: r.province || '',
|
||||
city: r.city || '',
|
||||
signature: r.signature || '',
|
||||
tags: tags,
|
||||
has_avatar: (r.imgFlag || 0) > 0,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// § CKB-2 群成员详细信息获取(扩展版)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 获取群成员详细信息(含每个成员的联系人信息)
|
||||
*
|
||||
* @param {string} groupId - 群ID(chatroomname)
|
||||
* @returns {{ success: boolean, group_id: string, members: Array, count: number }}
|
||||
*/
|
||||
function _getGroupMembersDetailFromDB(groupId) {
|
||||
if (!groupId) return { success: false, error: '缺少 group_id', members: [] };
|
||||
|
||||
// Step 1: 获取群成员列表
|
||||
var sql = "SELECT memberlist, displayname, roomowner, chatroomnick FROM chatroom"
|
||||
+ " WHERE chatroomname='" + groupId.replace(/'/g, "''") + "'";
|
||||
var rows = _execSQL(sql);
|
||||
if (rows.length === 0) return { success: false, error: '群不存在: ' + groupId, members: [] };
|
||||
|
||||
var r = rows[0];
|
||||
var memberWxids = (r.memberlist || '').split(';').filter(function(m) { return m.trim(); });
|
||||
var displayNames = (r.displayname || '').split(';');
|
||||
var owner = r.roomowner || '';
|
||||
|
||||
if (memberWxids.length === 0) {
|
||||
return { success: true, group_id: groupId, group_name: r.chatroomnick || '', members: [], count: 0 };
|
||||
}
|
||||
|
||||
// Step 2: 批量获取成员联系人信息(分批查询,每批50个)
|
||||
var memberInfoMap = {};
|
||||
var batchSize = 50;
|
||||
for (var i = 0; i < memberWxids.length; i += batchSize) {
|
||||
var batch = memberWxids.slice(i, i + batchSize);
|
||||
var inClause = batch.map(function(id) { return "'" + id.replace(/'/g, "''") + "'"; }).join(',');
|
||||
try {
|
||||
var infoSql = "SELECT username, nickname, conRemark, alias, sex, country, province, city "
|
||||
+ "FROM rcontact WHERE username IN (" + inClause + ")";
|
||||
var infoRows = _execSQL(infoSql);
|
||||
infoRows.forEach(function(ir) {
|
||||
memberInfoMap[ir.username] = ir;
|
||||
});
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// Step 3: 组装成员详细信息
|
||||
var sexMap = { '0': 'unknown', '1': 'male', '2': 'female' };
|
||||
var members = memberWxids.map(function(wxid, idx) {
|
||||
var info = memberInfoMap[wxid] || {};
|
||||
return {
|
||||
wxid: wxid,
|
||||
display_name: (displayNames[idx] || '').trim(),
|
||||
nickname: info.nickname || '',
|
||||
remark: info.conRemark || '',
|
||||
alias: info.alias || '',
|
||||
sex: sexMap[String(info.sex)] || 'unknown',
|
||||
country: info.country || '',
|
||||
province: info.province || '',
|
||||
city: info.city || '',
|
||||
is_owner: wxid === owner,
|
||||
in_contacts: !!info.nickname, // 是否在通讯录中
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
group_id: groupId,
|
||||
group_name: r.chatroomnick || '',
|
||||
owner: owner,
|
||||
members: members,
|
||||
count: members.length,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// § CKB-3 实时事件监听(新好友 / 群成员加入)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 启动新好友添加事件监听
|
||||
* 通过 SQLite insert Hook 监听 rcontact 表的新增记录
|
||||
*
|
||||
* 触发条件:verifyFlag=0 且 type=3(好友类型)的新记录
|
||||
*/
|
||||
function _startFriendAddListener() {
|
||||
try {
|
||||
// 监听 SQLite insert,过滤 rcontact 表的新好友
|
||||
// 此函数需要在 Java.perform 上下文中调用
|
||||
Java.perform(function() {
|
||||
var SQLiteDatabase = Java.use('android.database.sqlite.SQLiteDatabase');
|
||||
|
||||
SQLiteDatabase.insert.overload(
|
||||
'java.lang.String',
|
||||
'java.lang.String',
|
||||
'android.content.ContentValues'
|
||||
).implementation = function(table, nullColumnHack, values) {
|
||||
var result = this.insert(table, nullColumnHack, values);
|
||||
|
||||
if (table === 'rcontact') {
|
||||
try {
|
||||
var wxid = '';
|
||||
var nickname = '';
|
||||
var type = 0;
|
||||
var verifyFlag = 0;
|
||||
|
||||
// 从 ContentValues 读取字段
|
||||
if (values) {
|
||||
try { wxid = values.getAsString('username') || ''; } catch(e) {}
|
||||
try { nickname = values.getAsString('nickname') || ''; } catch(e) {}
|
||||
try { type = parseInt(values.getAsInteger('type') || 0); } catch(e) {}
|
||||
try { verifyFlag = parseInt(values.getAsInteger('verifyFlag') || 0); } catch(e) {}
|
||||
}
|
||||
|
||||
// 新好友条件:type=3(普通好友)且 verifyFlag=0(已通过验证)
|
||||
if (type === 3 && verifyFlag === 0 && wxid) {
|
||||
emitEvent('friend_add', {
|
||||
wxid: wxid,
|
||||
nickname: nickname,
|
||||
source: '微信添加',
|
||||
timestamp: Date.now(),
|
||||
// 标记需要上报到存客宝
|
||||
report_to_ckb: true,
|
||||
});
|
||||
log('info', 'ckb', '新好友添加事件: ' + wxid + ' (' + nickname + ')');
|
||||
}
|
||||
} catch(e) {
|
||||
log('warn', 'ckb', '解析新好友事件失败: ' + e);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
log('info', 'ckb', '新好友添加监听已启动');
|
||||
});
|
||||
return { success: true };
|
||||
} catch(e) {
|
||||
log('warn', 'ckb', '启动新好友监听失败: ' + e);
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动群成员加入事件监听
|
||||
* 通过监听 chatroom 表的 memberlist 字段变化来检测新成员
|
||||
*/
|
||||
function _startGroupMemberJoinListener() {
|
||||
// 缓存已知群成员,用于对比变化
|
||||
var _groupMemberCache = {};
|
||||
|
||||
try {
|
||||
Java.perform(function() {
|
||||
var SQLiteDatabase = Java.use('android.database.sqlite.SQLiteDatabase');
|
||||
|
||||
SQLiteDatabase.update.overload(
|
||||
'java.lang.String',
|
||||
'android.content.ContentValues',
|
||||
'java.lang.String',
|
||||
'[Ljava.lang.String;'
|
||||
).implementation = function(table, values, whereClause, whereArgs) {
|
||||
var result = this.update(table, values, whereClause, whereArgs);
|
||||
|
||||
if (table === 'chatroom') {
|
||||
try {
|
||||
var groupId = '';
|
||||
var newMemberList = '';
|
||||
|
||||
if (values) {
|
||||
try { newMemberList = values.getAsString('memberlist') || ''; } catch(e) {}
|
||||
}
|
||||
// 从 whereClause 提取群ID
|
||||
if (whereClause && whereClause.indexOf('chatroomname') !== -1 && whereArgs && whereArgs.length > 0) {
|
||||
groupId = whereArgs[0] || '';
|
||||
}
|
||||
|
||||
if (groupId && newMemberList) {
|
||||
var newMembers = newMemberList.split(';').filter(function(m) { return m.trim(); });
|
||||
var oldMembers = _groupMemberCache[groupId] || [];
|
||||
|
||||
// 找出新加入的成员
|
||||
var joinedMembers = newMembers.filter(function(m) {
|
||||
return oldMembers.indexOf(m) === -1;
|
||||
});
|
||||
|
||||
if (joinedMembers.length > 0) {
|
||||
joinedMembers.forEach(function(wxid) {
|
||||
emitEvent('group_member_join', {
|
||||
group_id: groupId,
|
||||
wxid: wxid,
|
||||
timestamp: Date.now(),
|
||||
report_to_ckb: true,
|
||||
});
|
||||
log('info', 'ckb', '群成员加入: ' + wxid + ' -> ' + groupId);
|
||||
});
|
||||
}
|
||||
|
||||
// 更新缓存
|
||||
_groupMemberCache[groupId] = newMembers;
|
||||
}
|
||||
} catch(e) {
|
||||
log('warn', 'ckb', '解析群成员变动失败: ' + e);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
log('info', 'ckb', '群成员加入监听已启动');
|
||||
});
|
||||
return { success: true };
|
||||
} catch(e) {
|
||||
log('warn', 'ckb', '启动群成员监听失败: ' + e);
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// § CKB-4 批量同步通讯录到存客宝
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 批量同步通讯录到存客宝
|
||||
* 获取全部联系人详细信息,通过 HTTP 上报到 SDK 服务端
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.server_url - SDK 服务端地址(如 http://192.168.1.100:8899)
|
||||
* @param {string} params.device_id - 设备ID
|
||||
* @param {number} params.limit - 最大同步数量,默认 500
|
||||
* @param {number} params.batch_size - 每批上报数量,默认 50
|
||||
*/
|
||||
function _batchSyncContactsToCKB(params) {
|
||||
var serverUrl = (params && params.server_url) || '';
|
||||
var deviceId = (params && params.device_id) || '';
|
||||
var limit = (params && params.limit) || 500;
|
||||
var batchSize = (params && params.batch_size) || 50;
|
||||
|
||||
if (!serverUrl || !deviceId) {
|
||||
return { success: false, error: '缺少 server_url 或 device_id' };
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取联系人详细信息
|
||||
var result = _getContactsDetailFromDB(limit);
|
||||
if (!result.success || result.contacts.length === 0) {
|
||||
return { success: false, error: '获取联系人失败或为空' };
|
||||
}
|
||||
|
||||
var contacts = result.contacts;
|
||||
var totalBatches = Math.ceil(contacts.length / batchSize);
|
||||
var successCount = 0;
|
||||
var failCount = 0;
|
||||
|
||||
log('info', 'ckb', '开始批量同步通讯录: ' + contacts.length + ' 个联系人,共 ' + totalBatches + ' 批');
|
||||
|
||||
// 使用 Java HTTP 客户端发送请求
|
||||
Java.perform(function() {
|
||||
var URL = Java.use('java.net.URL');
|
||||
var HttpURLConnection = Java.use('java.net.HttpURLConnection');
|
||||
var BufferedReader = Java.use('java.io.BufferedReader');
|
||||
var InputStreamReader = Java.use('java.io.InputStreamReader');
|
||||
var OutputStreamWriter = Java.use('java.io.OutputStreamWriter');
|
||||
var JSONObject = Java.use('org.json.JSONObject');
|
||||
var JSONArray = Java.use('org.json.JSONArray');
|
||||
|
||||
for (var i = 0; i < contacts.length; i += batchSize) {
|
||||
var batch = contacts.slice(i, i + batchSize);
|
||||
var batchNum = Math.floor(i / batchSize) + 1;
|
||||
|
||||
try {
|
||||
var url = new URL(serverUrl + '/api/v3/cunke-bao/batch-contacts');
|
||||
var conn = url.openConnection();
|
||||
conn.setRequestMethod('POST');
|
||||
conn.setRequestProperty('Content-Type', 'application/json');
|
||||
conn.setDoOutput(true);
|
||||
conn.setConnectTimeout(10000);
|
||||
conn.setReadTimeout(15000);
|
||||
|
||||
// 构建请求体
|
||||
var bodyObj = {
|
||||
device_id: deviceId,
|
||||
source: '微信通讯录同步',
|
||||
contacts: batch
|
||||
};
|
||||
var bodyStr = JSON.stringify(bodyObj);
|
||||
|
||||
var writer = new OutputStreamWriter(conn.getOutputStream());
|
||||
writer.write(bodyStr);
|
||||
writer.flush();
|
||||
writer.close();
|
||||
|
||||
var responseCode = conn.getResponseCode();
|
||||
if (responseCode === 200) {
|
||||
successCount += batch.length;
|
||||
log('info', 'ckb', '批次 ' + batchNum + '/' + totalBatches + ' 上报成功');
|
||||
} else {
|
||||
failCount += batch.length;
|
||||
log('warn', 'ckb', '批次 ' + batchNum + ' 上报失败,HTTP ' + responseCode);
|
||||
}
|
||||
conn.disconnect();
|
||||
} catch(batchErr) {
|
||||
failCount += batch.length;
|
||||
log('error', 'ckb', '批次 ' + batchNum + ' 异常: ' + batchErr);
|
||||
}
|
||||
|
||||
// 批次间延迟 200ms,避免服务端压力
|
||||
java.lang.Thread.sleep(200);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
total: contacts.length,
|
||||
success_count: successCount,
|
||||
fail_count: failCount,
|
||||
};
|
||||
} catch(e) {
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// § CKB rpc.exports 扩展(追加到现有 rpc.exports 对象)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 将以下方法追加到 wechat_hook_v2.js 的 rpc.exports 对象中:
|
||||
*
|
||||
* getContactsDetail: 获取联系人详细信息(含标签/性别/地区)
|
||||
* getContactDetail: 获取单个联系人详细信息
|
||||
* getGroupMembersDetail: 获取群成员详细信息(含联系人信息)
|
||||
* startFriendAddListener: 启动新好友添加事件监听
|
||||
* startGroupMemberListener: 启动群成员加入事件监听
|
||||
* batchSyncContacts: 批量同步通讯录到存客宝
|
||||
*/
|
||||
var _ckbExports = {
|
||||
// F-CKB-1: 获取联系人详细信息
|
||||
getContactsDetail: function(params) {
|
||||
var limit = (params && params.limit) || 500;
|
||||
try {
|
||||
return Java.performNow(function() { return _getContactsDetailFromDB(limit); });
|
||||
} catch(e) {
|
||||
return { success: false, error: String(e), contacts: [] };
|
||||
}
|
||||
},
|
||||
|
||||
// F-CKB-1: 获取单个联系人详细信息
|
||||
getContactDetail: function(params) {
|
||||
var wxid = (params && params.wxid) || '';
|
||||
if (!wxid) return { success: false, error: '缺少 wxid' };
|
||||
try {
|
||||
return Java.performNow(function() { return _getContactDetailFromDB(wxid); });
|
||||
} catch(e) {
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
},
|
||||
|
||||
// F-CKB-2: 获取群成员详细信息
|
||||
getGroupMembersDetail: function(params) {
|
||||
var groupId = (params && params.group_id) || '';
|
||||
if (!groupId) return { success: false, error: '缺少 group_id' };
|
||||
try {
|
||||
return Java.performNow(function() { return _getGroupMembersDetailFromDB(groupId); });
|
||||
} catch(e) {
|
||||
return { success: false, error: String(e), members: [] };
|
||||
}
|
||||
},
|
||||
|
||||
// F-CKB-3: 启动新好友添加事件监听
|
||||
startFriendAddListener: function() {
|
||||
return _startFriendAddListener();
|
||||
},
|
||||
|
||||
// F-CKB-3: 启动群成员加入事件监听
|
||||
startGroupMemberListener: function() {
|
||||
return _startGroupMemberJoinListener();
|
||||
},
|
||||
|
||||
// F-CKB-4: 批量同步通讯录到存客宝
|
||||
batchSyncContacts: function(params) {
|
||||
try {
|
||||
return Java.performNow(function() { return _batchSyncContactsToCKB(params); });
|
||||
} catch(e) {
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// 将 CKB 扩展方法合并到 rpc.exports
|
||||
// (此行需要在 wechat_hook_v2.js 的 rpc.exports 定义之后执行)
|
||||
if (typeof rpc !== 'undefined' && rpc.exports) {
|
||||
Object.assign(rpc.exports, _ckbExports);
|
||||
log('info', 'ckb', 'CKB 扩展 Hook 已注入: getContactsDetail/getGroupMembersDetail/startFriendAddListener/startGroupMemberListener/batchSyncContacts');
|
||||
}
|
||||
Reference in New Issue
Block a user