feat: 任务发布到企微客户群(企业群发)与资源库单条新增
- 任务中心新增「发布到企微群」:同步客户群清单(wecom_group_chats)、 按群主分组创建企业群发任务(add_msg_template)、发送记录落库 wecom_group_pushes,迁移 mysql/0009;lib/wecom-client.ts 补 listCustomerGroupChats/createGroupMsgTemplate - KOC 资源库支持单条新增:app/api/resources-insert + lib/resource-write, 拆出资源写入公共逻辑供导入复用;0008 补合作方外部联系人字段 - 环境变量示例补企微凭证与 SEED_DEMO_DATA;next.config 增加 allowedDevOrigins;CLAUDE.md 补充项目说明 - .gitignore 排除 .codegraph/ 与 .ipynb_checkpoints/
This commit is contained in:
@@ -25,6 +25,31 @@ type WecomEnvelope = {
|
||||
access_token?: string;
|
||||
expires_in?: number;
|
||||
invaliduser?: string;
|
||||
follow_user?: Array<{ userid: string }>;
|
||||
external_userid?: string[];
|
||||
external_contact?: {
|
||||
external_userid?: string;
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
corp_fullname?: string;
|
||||
};
|
||||
next_cursor?: string;
|
||||
group_chat_list?: Array<{ chat_id?: string; status?: number }>;
|
||||
group_chat?: {
|
||||
name?: string;
|
||||
owner?: string;
|
||||
member_count?: number;
|
||||
};
|
||||
fail_list?: string[];
|
||||
msgid?: string;
|
||||
};
|
||||
|
||||
export type WecomExternalContact = {
|
||||
externalUserId: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
corpName: string;
|
||||
ownerUserId: string;
|
||||
};
|
||||
|
||||
type CachedAccessToken = {
|
||||
@@ -217,3 +242,219 @@ export async function sendWecomAppMessage(
|
||||
export function clearWecomAccessTokenCacheForTests() {
|
||||
cachedAccessToken = null;
|
||||
}
|
||||
|
||||
export type WecomGroupChat = {
|
||||
chatId: string;
|
||||
name: string;
|
||||
ownerUserId: string;
|
||||
memberCount: number;
|
||||
status: number;
|
||||
};
|
||||
|
||||
export async function listCustomerGroupChats(
|
||||
config: WecomConfig,
|
||||
fetchImpl: FetchLike = fetch,
|
||||
): Promise<WecomGroupChat[]> {
|
||||
if (!hasAppCredentials(config)) return [];
|
||||
const token = await fetchAccessToken(config, fetchImpl);
|
||||
|
||||
const chatIds: Array<{ chatId: string; status: number }> = [];
|
||||
let cursor = "";
|
||||
do {
|
||||
const listUrl = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/groupchat/list?access_token=${encodeURIComponent(token)}`;
|
||||
const listResp = await fetchImpl(listUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
body: JSON.stringify({ limit: 100, cursor }),
|
||||
signal: AbortSignal.timeout(12_000),
|
||||
});
|
||||
const listPayload = await readEnvelope(listResp, "获取企业微信客户群列表失败");
|
||||
if (!listResp.ok) {
|
||||
throw new WecomClientError(
|
||||
`获取企业微信客户群列表失败(HTTP ${listResp.status})`,
|
||||
502,
|
||||
);
|
||||
}
|
||||
ensureOk(listPayload, "获取企业微信客户群列表失败");
|
||||
for (const chat of listPayload.group_chat_list ?? []) {
|
||||
const chatId = bindingValue(chat.chat_id);
|
||||
if (chatId) chatIds.push({ chatId, status: Number(chat.status ?? 0) });
|
||||
}
|
||||
cursor = bindingValue(listPayload.next_cursor);
|
||||
} while (cursor);
|
||||
|
||||
const groups: WecomGroupChat[] = [];
|
||||
for (const { chatId, status } of chatIds) {
|
||||
if (status !== 0) continue;
|
||||
const getUrl = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/groupchat/get?access_token=${encodeURIComponent(token)}`;
|
||||
const getResp = await fetchImpl(getUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
body: JSON.stringify({ chat_id: chatId }),
|
||||
signal: AbortSignal.timeout(12_000),
|
||||
});
|
||||
const getPayload = await readEnvelope(getResp, "获取企业微信客户群详情失败");
|
||||
if (!getResp.ok) continue;
|
||||
if (Number(getPayload.errcode ?? 0) !== 0) continue;
|
||||
const detail = getPayload.group_chat;
|
||||
groups.push({
|
||||
chatId,
|
||||
name: bindingValue(detail?.name),
|
||||
ownerUserId: bindingValue(detail?.owner),
|
||||
memberCount: Number(detail?.member_count ?? 0),
|
||||
status,
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export type WecomGroupMsgLink = {
|
||||
title: string;
|
||||
desc?: string;
|
||||
url: string;
|
||||
picurl?: string;
|
||||
};
|
||||
|
||||
export async function createGroupMsgTemplate(
|
||||
config: WecomConfig,
|
||||
params: {
|
||||
sender: string;
|
||||
chatIdList: string[];
|
||||
text?: string;
|
||||
link?: WecomGroupMsgLink;
|
||||
},
|
||||
fetchImpl: FetchLike = fetch,
|
||||
): Promise<{ msgid: string; failList: string[] }> {
|
||||
if (!hasAppCredentials(config)) {
|
||||
throw new WecomClientError(
|
||||
"企业微信自建应用未配置(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)",
|
||||
400,
|
||||
);
|
||||
}
|
||||
const chatIdList = params.chatIdList.map(bindingValue).filter(Boolean);
|
||||
if (chatIdList.length === 0) {
|
||||
throw new WecomClientError("客户群列表为空", 400);
|
||||
}
|
||||
const sender = bindingValue(params.sender);
|
||||
if (!sender) {
|
||||
throw new WecomClientError("发送成员(群主 userid)不能为空", 400);
|
||||
}
|
||||
const text = String(params.text ?? "").trim();
|
||||
const link = params.link;
|
||||
if (!text && !link) {
|
||||
throw new WecomClientError("文本与图文附件不能同时为空", 400);
|
||||
}
|
||||
|
||||
const token = await fetchAccessToken(config, fetchImpl);
|
||||
const url = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/add_msg_template?access_token=${encodeURIComponent(token)}`;
|
||||
const response = await fetchImpl(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
body: JSON.stringify({
|
||||
chat_type: "group",
|
||||
chat_id_list: chatIdList,
|
||||
sender,
|
||||
...(text ? { text: { content: text } } : {}),
|
||||
...(link
|
||||
? {
|
||||
attachments: [
|
||||
{
|
||||
msgtype: "link",
|
||||
link: {
|
||||
title: link.title,
|
||||
desc: link.desc ?? "",
|
||||
url: link.url,
|
||||
picurl: link.picurl ?? "",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
signal: AbortSignal.timeout(12_000),
|
||||
});
|
||||
const payload = await readEnvelope(response, "创建企业群发失败");
|
||||
if (!response.ok) {
|
||||
throw new WecomClientError(
|
||||
`创建企业群发失败(HTTP ${response.status})`,
|
||||
502,
|
||||
);
|
||||
}
|
||||
ensureOk(payload, "创建企业群发失败");
|
||||
return {
|
||||
msgid: bindingValue(payload.msgid),
|
||||
failList: (payload.fail_list ?? []).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listExternalContacts(
|
||||
config: WecomConfig,
|
||||
fetchImpl: FetchLike = fetch,
|
||||
): Promise<WecomExternalContact[]> {
|
||||
if (!hasAppCredentials(config)) return [];
|
||||
const token = await fetchAccessToken(config, fetchImpl);
|
||||
|
||||
const followUrl = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/get_follow_user_list?access_token=${encodeURIComponent(token)}`;
|
||||
const followResp = await fetchImpl(followUrl, {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(12_000),
|
||||
});
|
||||
const followPayload = await readEnvelope(
|
||||
followResp,
|
||||
"获取企业微信跟进人列表失败",
|
||||
);
|
||||
if (!followResp.ok) {
|
||||
throw new WecomClientError(
|
||||
`获取企业微信跟进人列表失败(HTTP ${followResp.status})`,
|
||||
502,
|
||||
);
|
||||
}
|
||||
ensureOk(followPayload, "获取企业微信跟进人列表失败");
|
||||
const ownerUserIds = (followPayload.follow_user ?? [])
|
||||
.map((user) => bindingValue(user.userid))
|
||||
.filter(Boolean);
|
||||
|
||||
const ownerToExternalIds: Array<{ externalUserId: string; ownerUserId: string }> = [];
|
||||
for (const ownerUserId of ownerUserIds) {
|
||||
const listUrl = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/list?access_token=${encodeURIComponent(token)}&userid=${encodeURIComponent(ownerUserId)}`;
|
||||
const listResp = await fetchImpl(listUrl, {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(12_000),
|
||||
});
|
||||
const listPayload = await readEnvelope(
|
||||
listResp,
|
||||
"获取企业微信外部联系人列表失败",
|
||||
);
|
||||
if (!listResp.ok) continue;
|
||||
if (Number(listPayload.errcode ?? 0) !== 0) continue;
|
||||
const externalIds = (listPayload.external_userid ?? []).filter(Boolean);
|
||||
for (const externalUserId of externalIds) {
|
||||
ownerToExternalIds.push({ externalUserId, ownerUserId });
|
||||
}
|
||||
}
|
||||
|
||||
const contacts: WecomExternalContact[] = [];
|
||||
for (const { externalUserId, ownerUserId } of ownerToExternalIds) {
|
||||
const getUrl = `${WECOM_API_ORIGIN}/cgi-bin/externalcontact/get?access_token=${encodeURIComponent(token)}&external_userid=${encodeURIComponent(externalUserId)}`;
|
||||
const getResp = await fetchImpl(getUrl, {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(12_000),
|
||||
});
|
||||
const getPayload = await readEnvelope(
|
||||
getResp,
|
||||
"获取企业微信外部联系人详情失败",
|
||||
);
|
||||
if (!getResp.ok) continue;
|
||||
if (Number(getPayload.errcode ?? 0) !== 0) continue;
|
||||
const info = getPayload.external_contact;
|
||||
if (!info) continue;
|
||||
contacts.push({
|
||||
externalUserId,
|
||||
name: bindingValue(info.name),
|
||||
avatar: bindingValue(info.avatar),
|
||||
corpName: bindingValue(info.corp_fullname) || bindingValue(info.name),
|
||||
ownerUserId,
|
||||
});
|
||||
}
|
||||
return contacts;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user