mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 01:53:21 +08:00
Surface chat/access-list contacts as selectable targets with clearer nicknames, and keep conversation peers usable for ops schedulers. Co-authored-by: Cursor <cursoragent@cursor.com>
268 lines
8.8 KiB
JavaScript
268 lines
8.8 KiB
JavaScript
const CHANNELS = new Set([
|
|
'weixin',
|
|
'feishu',
|
|
'dingtalk',
|
|
'wecom',
|
|
'qq',
|
|
'slack',
|
|
'telegram',
|
|
'discord',
|
|
'whatsapp',
|
|
]);
|
|
|
|
function isRecord(value) {
|
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function opaqueId(value) {
|
|
return typeof value === 'string'
|
|
&& value.length > 0
|
|
&& value.length <= 512
|
|
&& value.trim() === value
|
|
&& !/[\s:\u0000-\u001f\u007f]/u.test(value);
|
|
}
|
|
|
|
function afterPrefix(key, prefix) {
|
|
const marker = `${prefix}:`;
|
|
if (!key.startsWith(marker)) return null;
|
|
const value = key.slice(marker.length);
|
|
return opaqueId(value) ? value : null;
|
|
}
|
|
|
|
function simpleSuggestion(key, definitions) {
|
|
for (const [prefix, kind, field] of definitions) {
|
|
const value = afterPrefix(key, prefix);
|
|
if (value) return { kind, route: { [field]: value } };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function feishuSuggestion(key) {
|
|
const openId = afterPrefix(key, 'p2p');
|
|
if (openId?.startsWith('ou_')) {
|
|
return { kind: 'user', route: { openId } };
|
|
}
|
|
return simpleSuggestion(key, [['group', 'group', 'chatId']]);
|
|
}
|
|
|
|
function slackSuggestion(key) {
|
|
const directChannel = afterPrefix(key, 'direct');
|
|
if (directChannel && /^[A-Za-z0-9_-]{1,128}$/.test(directChannel)) {
|
|
return { kind: 'conversation', route: { channelId: directChannel } };
|
|
}
|
|
const match = /^group:([^:]+):(\d{1,20}(?:\.\d{1,20})?)$/.exec(key);
|
|
if (!match || !/^[A-Za-z0-9_-]{1,128}$/.test(match[1])) return null;
|
|
return { kind: 'thread', route: { channelId: match[1], threadTs: match[2] } };
|
|
}
|
|
|
|
function telegramSuggestion(key) {
|
|
const match = /^(direct|group):(-?\d+)(?::([1-9]\d*))?$/.exec(key);
|
|
if (!match) return null;
|
|
const chatId = Number(match[2]);
|
|
if (!Number.isSafeInteger(chatId)) return null;
|
|
if (match[1] === 'direct' && match[3] !== undefined) return null;
|
|
if (match[3] === undefined) return { kind: 'chat', route: { chatId: match[2] } };
|
|
const messageThreadId = Number(match[3]);
|
|
if (!Number.isSafeInteger(messageThreadId) || messageThreadId <= 0) return null;
|
|
return { kind: 'topic', route: { chatId: match[2], messageThreadId } };
|
|
}
|
|
|
|
function discordSuggestion(key) {
|
|
const match = /^(?:direct|group):(\d{1,32})$/.exec(key);
|
|
return match ? { kind: 'channel', route: { channelId: match[1] } } : null;
|
|
}
|
|
|
|
function whatsappSuggestion(key) {
|
|
const direct = /^direct:(\d{5,32}@(s\.whatsapp\.net|lid))$/.exec(key);
|
|
if (direct) return { kind: 'user', route: { jid: direct[1] } };
|
|
const group = /^group:(\d{5,32}(?:-\d{1,32})?@g\.us)$/.exec(key);
|
|
return group ? { kind: 'group', route: { jid: group[1] } } : null;
|
|
}
|
|
|
|
/** Convert one persisted conversation key into a stable proactive-delivery route. */
|
|
export function deliverySuggestionFromConversationKey(channel, key) {
|
|
if (!CHANNELS.has(channel) || typeof key !== 'string') return null;
|
|
switch (channel) {
|
|
case 'weixin':
|
|
return simpleSuggestion(key, [['p2p', 'user', 'toUserId']]);
|
|
case 'feishu':
|
|
return feishuSuggestion(key);
|
|
case 'dingtalk':
|
|
return simpleSuggestion(key, [
|
|
['p2p', 'user', 'userId'],
|
|
['group', 'group', 'openConversationId'],
|
|
]);
|
|
case 'wecom':
|
|
return simpleSuggestion(key, [
|
|
['direct', 'user', 'chatId'],
|
|
['group', 'group', 'chatId'],
|
|
]);
|
|
case 'qq':
|
|
return simpleSuggestion(key, [
|
|
['c2c', 'user', 'userOpenId'],
|
|
['group', 'group', 'groupOpenId'],
|
|
]);
|
|
case 'slack':
|
|
return slackSuggestion(key);
|
|
case 'telegram':
|
|
return telegramSuggestion(key);
|
|
case 'discord':
|
|
return discordSuggestion(key);
|
|
case 'whatsapp':
|
|
return whatsappSuggestion(key);
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract and de-duplicate stable delivery routes from a persisted sessions map.
|
|
* Session ids and every other state field are intentionally ignored.
|
|
*/
|
|
export function deliverySuggestionsFromSessions(channel, sessions) {
|
|
if (!CHANNELS.has(channel) || !isRecord(sessions)) return [];
|
|
const suggestions = [];
|
|
const seen = new Set();
|
|
for (const key of Object.keys(sessions)) {
|
|
const suggestion = deliverySuggestionFromConversationKey(channel, key);
|
|
if (!suggestion) continue;
|
|
const identity = JSON.stringify(suggestion);
|
|
if (seen.has(identity)) continue;
|
|
seen.add(identity);
|
|
suggestions.push(suggestion);
|
|
}
|
|
return suggestions;
|
|
}
|
|
|
|
function clipName(value) {
|
|
if (typeof value !== 'string') return '';
|
|
const text = value.trim().replace(/\s+/g, ' ');
|
|
return text ? text.slice(0, 80) : '';
|
|
}
|
|
|
|
/** Prefer「昵称 · 电话」so ops pickers can tell people apart. */
|
|
export function whatsappUserDisplayName(pushName, phone) {
|
|
const nick = clipName(pushName);
|
|
const number = typeof phone === 'string' ? phone.trim() : '';
|
|
if (nick && number) return clipName(`${nick} · ${number}`);
|
|
if (nick) return nick;
|
|
if (number) return number;
|
|
return '';
|
|
}
|
|
|
|
function whatsappGroupDisplayName(title, jid) {
|
|
const name = clipName(title);
|
|
if (name) return name;
|
|
const raw = typeof jid === 'string' ? jid.trim() : '';
|
|
if (!raw) return '';
|
|
const local = raw.includes('@') ? raw.slice(0, raw.indexOf('@')) : raw;
|
|
if (local.length <= 10) return clipName(`群 · ${local}`);
|
|
return clipName(`群 · ${local.slice(0, 6)}…${local.slice(-4)}`);
|
|
}
|
|
|
|
function suggestionKey(suggestion) {
|
|
return `${suggestion?.kind ?? ''}\0${suggestion?.route?.jid ?? ''}`;
|
|
}
|
|
|
|
/**
|
|
* Merge WhatsApp session routes with access-grant contacts/groups so the
|
|
* delivery picker shows nickname+phone and known groups, not only opaque JIDs.
|
|
*
|
|
* @param {Array<{ kind: string, route: { jid: string }, name?: string }>} sessionSuggestions
|
|
* @param {object|null|undefined} grant - WhatsApp accessGrant document
|
|
* @returns {Array<{ kind: string, route: { jid: string }, name?: string }>}
|
|
*/
|
|
export function enrichWhatsappDeliverySuggestions(sessionSuggestions, grant) {
|
|
const byKey = new Map();
|
|
const lidToPhone = new Map();
|
|
const contacts = Array.isArray(grant?.contacts) ? grant.contacts : [];
|
|
for (const contact of contacts) {
|
|
const phone = typeof contact?.phone === 'string' ? contact.phone.trim() : '';
|
|
if (!phone) continue;
|
|
for (const lid of Array.isArray(contact.lids) ? contact.lids : []) {
|
|
const id = typeof lid === 'string' ? lid.trim() : '';
|
|
if (id) lidToPhone.set(id, phone);
|
|
}
|
|
}
|
|
|
|
const put = (suggestion) => {
|
|
if (!suggestion?.kind || !suggestion?.route?.jid) return;
|
|
const key = suggestionKey(suggestion);
|
|
const previous = byKey.get(key);
|
|
if (!previous) {
|
|
byKey.set(key, suggestion);
|
|
return;
|
|
}
|
|
if (!previous.name && suggestion.name) byKey.set(key, { ...previous, name: suggestion.name });
|
|
else if (previous.name && suggestion.name && suggestion.name.length > previous.name.length) {
|
|
byKey.set(key, { ...previous, name: suggestion.name });
|
|
}
|
|
};
|
|
|
|
for (const raw of Array.isArray(sessionSuggestions) ? sessionSuggestions : []) {
|
|
if (raw?.kind === 'user' && typeof raw?.route?.jid === 'string') {
|
|
const jid = raw.route.jid;
|
|
const lidMatch = /^(\d{5,32})@lid$/.exec(jid);
|
|
if (lidMatch) {
|
|
const phone = lidToPhone.get(lidMatch[1]);
|
|
if (phone) {
|
|
put({
|
|
kind: 'user',
|
|
route: { jid: `${phone}@s.whatsapp.net` },
|
|
...(raw.name ? { name: clipName(raw.name) } : {}),
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
put({
|
|
kind: raw.kind,
|
|
route: { jid: raw.route.jid },
|
|
...(raw.name ? { name: clipName(raw.name) } : {}),
|
|
});
|
|
}
|
|
|
|
for (const contact of contacts) {
|
|
const phone = typeof contact?.phone === 'string' ? contact.phone.trim() : '';
|
|
const lids = Array.isArray(contact?.lids)
|
|
? contact.lids.map((lid) => String(lid ?? '').trim()).filter(Boolean)
|
|
: [];
|
|
const name = whatsappUserDisplayName(contact?.pushName, phone);
|
|
if (phone) {
|
|
put({
|
|
kind: 'user',
|
|
route: { jid: `${phone}@s.whatsapp.net` },
|
|
...(name ? { name } : {}),
|
|
});
|
|
continue;
|
|
}
|
|
for (const lid of lids.slice(0, 1)) {
|
|
put({
|
|
kind: 'user',
|
|
route: { jid: `${lid}@lid` },
|
|
...(name ? { name } : {}),
|
|
});
|
|
}
|
|
}
|
|
|
|
const groups = grant?.groups && typeof grant.groups === 'object' && !Array.isArray(grant.groups)
|
|
? grant.groups
|
|
: {};
|
|
for (const [groupJid, entry] of Object.entries(groups)) {
|
|
if (typeof groupJid !== 'string' || !/^\d{5,32}(?:-\d{1,32})?@g\.us$/.test(groupJid)) continue;
|
|
const name = whatsappGroupDisplayName(entry?.title, groupJid);
|
|
put({
|
|
kind: 'group',
|
|
route: { jid: groupJid },
|
|
...(name ? { name } : {}),
|
|
});
|
|
}
|
|
|
|
const rows = [...byKey.values()];
|
|
rows.sort((left, right) => (
|
|
String(left.kind).localeCompare(String(right.kind))
|
|
|| String(left.name || '').localeCompare(String(right.name || ''), 'zh')
|
|
|| String(left.route.jid).localeCompare(String(right.route.jid))
|
|
));
|
|
return rows;
|
|
}
|