feat: 项目总群自动建群+任务负责人后台视角+客户群活码进群+任务编辑
- 项目总群:≥3 人 appchat/create 自动建群(chatid=pgc+projectId),成员 diff 增删同步;DROP project_group_chats;推送改 appchat/send 群发、无群降级逐人 - 任务负责人:tasks.owner_userid(新建/编辑任务通讯录选人),task_set_owner 分配即预建 tgc 任务群发介绍,群主链接只归属负责人 - 任务编辑 task_update:基础字段+截图任务数量/关键词/说明,contents 调平只删 available 尾行 - 删除「一键推送」:task-one-click-push-service 整体移除(recordCardAck 移入 wecom-callback-events) - 负责人后台视角:users.wecom_userid 绑定,lib/owner-scope 行级过滤(undefined=全量/""=空范围/非空=过滤),~30 action 补 managerGate/taskOwnerGate,owner 登录仅见工作台/任务中心/数据回收/复盘报表 - 任务绑定客户群+进群活码:tasks 增 customer_chat_id/name、join_config_id、join_qr_code 4 列,join_way add/update/get/del(换绑走 update 保 config_id 活码不变),后台「客户群」弹窗,portal 认领后扫码自助进群(满员自动开新群) - 迁移 0013/0014/0015;催办支持按任务;企微可信域名 IP 验证文件 - 288 测试全绿;真机全链路验收通过(列群/活码四端点/portal 渲染/解绑清空)
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
addTaskJoinWay,
|
||||
buildWecomOAuthUrl,
|
||||
clearWecomAccessTokenCacheForTests,
|
||||
createAppChat,
|
||||
createGroupMsgTemplate,
|
||||
deleteJoinWay,
|
||||
exchangeWecomOAuthCode,
|
||||
fetchWecomExternalContactName,
|
||||
getAppChat,
|
||||
getJoinWayQrCode,
|
||||
listCustomerGroupChats,
|
||||
listExternalContacts,
|
||||
listInternalContacts,
|
||||
@@ -16,6 +19,7 @@ import {
|
||||
sendWecomAppMessage,
|
||||
sendWecomRobotMessage,
|
||||
updateAppChat,
|
||||
updateTaskJoinWay,
|
||||
WecomClientError,
|
||||
} from "../lib/wecom-client.ts";
|
||||
|
||||
@@ -428,6 +432,14 @@ function fakeGroupPush(options = {}) {
|
||||
"wr-normal-1": { name: "KOC 一群", owner: "zhangsan", member_count: 42 },
|
||||
"wr-normal-2": { name: "KOC 二群", owner: "lisi", member_count: 7 },
|
||||
};
|
||||
const joinWay = {
|
||||
addErrcode: options.joinAddErrcode ?? 0,
|
||||
updateErrcode: options.joinUpdateErrcode ?? 0,
|
||||
getErrcode: options.joinGetErrcode ?? 0,
|
||||
delErrcode: options.joinDelErrcode ?? 0,
|
||||
qrCode: options.joinQrCode ?? "https://p.qpic.cn/qr-test.png",
|
||||
configId: options.joinConfigId ?? "cfgtest123",
|
||||
};
|
||||
const fetchImpl = async (input, init = {}) => {
|
||||
const url = new URL(String(input));
|
||||
calls.push({ url, init });
|
||||
@@ -466,6 +478,34 @@ function fakeGroupPush(options = {}) {
|
||||
fail_list: options.failList ?? [],
|
||||
});
|
||||
}
|
||||
if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/add_join_way")) {
|
||||
if (joinWay.addErrcode !== 0) {
|
||||
return Response.json({ errcode: joinWay.addErrcode, errmsg: "add failed" });
|
||||
}
|
||||
return Response.json({ errcode: 0, errmsg: "ok", config_id: joinWay.configId });
|
||||
}
|
||||
if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/get_join_way")) {
|
||||
if (joinWay.getErrcode !== 0) {
|
||||
return Response.json({ errcode: joinWay.getErrcode, errmsg: "get failed" });
|
||||
}
|
||||
return Response.json({
|
||||
errcode: 0,
|
||||
errmsg: "ok",
|
||||
join_way: { qr_code: joinWay.qrCode, scene: 2 },
|
||||
});
|
||||
}
|
||||
if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/update_join_way")) {
|
||||
if (joinWay.updateErrcode !== 0) {
|
||||
return Response.json({ errcode: joinWay.updateErrcode, errmsg: "update failed" });
|
||||
}
|
||||
return Response.json({ errcode: 0, errmsg: "ok" });
|
||||
}
|
||||
if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/del_join_way")) {
|
||||
if (joinWay.delErrcode !== 0) {
|
||||
return Response.json({ errcode: joinWay.delErrcode, errmsg: "del failed" });
|
||||
}
|
||||
return Response.json({ errcode: 0, errmsg: "ok" });
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
};
|
||||
return { calls, fetchImpl };
|
||||
@@ -524,6 +564,118 @@ test("listCustomerGroupChats skips groups whose detail lookup fails", async () =
|
||||
);
|
||||
});
|
||||
|
||||
test("listCustomerGroupChats sends owner_filter when ownerUserids given", async () => {
|
||||
const { calls, fetchImpl } = fakeGroupPush({
|
||||
pages: [{ next_cursor: "", group_chat_list: [{ chat_id: "wr-normal-1", status: 0 }] }],
|
||||
details: {
|
||||
"wr-normal-1": { name: "KOC 一群", owner: "zhangsan", member_count: 42 },
|
||||
},
|
||||
});
|
||||
const groups = await listCustomerGroupChats(
|
||||
resolveWecomConfig(baseBindings),
|
||||
fetchImpl,
|
||||
["zhangsan", ""],
|
||||
);
|
||||
assert.equal(groups.length, 1);
|
||||
const listCall = calls.find((c) =>
|
||||
c.url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/list"),
|
||||
);
|
||||
const body = JSON.parse(String(listCall.init.body));
|
||||
assert.deepEqual(body.owner_filter, { userid_list: ["zhangsan"] });
|
||||
});
|
||||
|
||||
test("addTaskJoinWay posts scene/auto_create_room/payload and returns configId", async () => {
|
||||
const { calls, fetchImpl } = fakeGroupPush();
|
||||
const result = await addTaskJoinWay(
|
||||
resolveWecomConfig(baseBindings),
|
||||
{
|
||||
chatIdList: ["wr-normal-1"],
|
||||
roomBaseName: "每".repeat(50),
|
||||
state: "task-task-baf7fd59",
|
||||
},
|
||||
fetchImpl,
|
||||
);
|
||||
assert.equal(result.configId, "cfgtest123");
|
||||
const call = calls.find((c) =>
|
||||
c.url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/add_join_way"),
|
||||
);
|
||||
assert.ok(call, "add_join_way was called");
|
||||
assert.equal(call.url.searchParams.get("access_token"), "token-test");
|
||||
const body = JSON.parse(String(call.init.body));
|
||||
assert.equal(body.scene, 2);
|
||||
assert.equal(body.auto_create_room, 1);
|
||||
assert.deepEqual(body.chat_id_list, ["wr-normal-1"]);
|
||||
assert.equal(body.room_base_name.length, 40, "room_base_name truncated to 40 code points");
|
||||
assert.equal(body.state, "task-task-baf7fd59");
|
||||
});
|
||||
|
||||
test("addTaskJoinWay surfaces upstream errcode as WecomClientError", async () => {
|
||||
const { fetchImpl } = fakeGroupPush({ joinAddErrcode: 60011 });
|
||||
await assert.rejects(
|
||||
() =>
|
||||
addTaskJoinWay(
|
||||
resolveWecomConfig(baseBindings),
|
||||
{ chatIdList: ["wr-normal-1"], roomBaseName: "群", state: "task-t1" },
|
||||
fetchImpl,
|
||||
),
|
||||
(error) =>
|
||||
error instanceof WecomClientError && /创建企业微信进群活码失败/.test(error.message),
|
||||
);
|
||||
});
|
||||
|
||||
test("getJoinWayQrCode returns qr_code url from join_way", async () => {
|
||||
const { calls, fetchImpl } = fakeGroupPush({ joinQrCode: "https://p.qpic.cn/qr-x.png" });
|
||||
const result = await getJoinWayQrCode(
|
||||
resolveWecomConfig(baseBindings),
|
||||
"cfgtest123",
|
||||
fetchImpl,
|
||||
);
|
||||
assert.equal(result.qrCode, "https://p.qpic.cn/qr-x.png");
|
||||
const call = calls.find((c) =>
|
||||
c.url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/get_join_way"),
|
||||
);
|
||||
assert.deepEqual(JSON.parse(String(call.init.body)), { config_id: "cfgtest123" });
|
||||
});
|
||||
|
||||
test("updateTaskJoinWay re-sends full body including scene for overwrite semantics", async () => {
|
||||
const { calls, fetchImpl } = fakeGroupPush();
|
||||
await updateTaskJoinWay(
|
||||
resolveWecomConfig(baseBindings),
|
||||
{
|
||||
configId: "cfgtest123",
|
||||
chatIdList: ["wr-normal-2"],
|
||||
roomBaseName: "新群名",
|
||||
state: "task-t2",
|
||||
},
|
||||
fetchImpl,
|
||||
);
|
||||
const call = calls.find((c) =>
|
||||
c.url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/update_join_way"),
|
||||
);
|
||||
const body = JSON.parse(String(call.init.body));
|
||||
assert.equal(body.config_id, "cfgtest123");
|
||||
assert.equal(body.scene, 2);
|
||||
assert.equal(body.auto_create_room, 1);
|
||||
assert.deepEqual(body.chat_id_list, ["wr-normal-2"]);
|
||||
assert.equal(body.room_base_name, "新群名");
|
||||
});
|
||||
|
||||
test("deleteJoinWay tolerates upstream rejection but still calls del endpoint", async () => {
|
||||
const { calls, fetchImpl } = fakeGroupPush({ joinDelErrcode: 40068 });
|
||||
await deleteJoinWay(resolveWecomConfig(baseBindings), "cfgtest123", fetchImpl);
|
||||
const call = calls.find((c) =>
|
||||
c.url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/del_join_way"),
|
||||
);
|
||||
assert.ok(call, "del_join_way was called");
|
||||
assert.deepEqual(JSON.parse(String(call.init.body)), { config_id: "cfgtest123" });
|
||||
});
|
||||
|
||||
test("deleteJoinWay skips empty configId", async () => {
|
||||
const { calls, fetchImpl } = fakeGroupPush();
|
||||
await deleteJoinWay(resolveWecomConfig(baseBindings), " ", fetchImpl);
|
||||
assert.equal(calls.length, 0, "no API calls for empty config id");
|
||||
});
|
||||
|
||||
test("createGroupMsgTemplate posts group payload with text and link attachment", async () => {
|
||||
const { calls, fetchImpl } = fakeGroupPush();
|
||||
const result = await createGroupMsgTemplate(
|
||||
@@ -834,6 +986,7 @@ function fakeAppChat(options = {}) {
|
||||
["tgc_task-existing", { name: "【任务】已有群", userlist: ["owner-a"] }],
|
||||
],
|
||||
);
|
||||
const createExisted = options.createExisted ?? false;
|
||||
const fetchImpl = async (input, init = {}) => {
|
||||
const url = new URL(String(input));
|
||||
calls.push({ url, init });
|
||||
@@ -846,6 +999,9 @@ function fakeAppChat(options = {}) {
|
||||
});
|
||||
}
|
||||
if (url.pathname.endsWith("/cgi-bin/appchat/create")) {
|
||||
if (createExisted) {
|
||||
return Response.json({ errcode: 86215, errmsg: "chatid existed" });
|
||||
}
|
||||
const body = JSON.parse(String(init.body ?? "{}"));
|
||||
chats.set(body.chatid, { name: body.name, userlist: body.userlist });
|
||||
return Response.json({ errcode: 0, errmsg: "ok", chatid: body.chatid });
|
||||
@@ -893,15 +1049,15 @@ test("createAppChat posts name/userlist/chatid and returns chatid", async () =>
|
||||
const { calls, fetchImpl } = fakeAppChat();
|
||||
const result = await createAppChat(
|
||||
resolveWecomConfig(baseBindings),
|
||||
{ name: "【任务】任务名超长会被截断到二十个字符", userlist: ["owner-a", "owner-b"], chatid: "tgc_task-new" },
|
||||
{ name: "【任务】任务名超长会被截断到二十个字符", userlist: ["owner-a", "owner-b"], chatid: "tgcnewchat" },
|
||||
fetchImpl,
|
||||
);
|
||||
assert.equal(result.chatid, "tgc_task-new");
|
||||
assert.equal(result.chatid, "tgcnewchat");
|
||||
const call = calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/create"));
|
||||
assert.ok(call, "appchat/create was called");
|
||||
assert.equal(call.url.searchParams.get("access_token"), "token-test");
|
||||
const body = JSON.parse(String(call.init.body));
|
||||
assert.equal(body.chatid, "tgc_task-new");
|
||||
assert.equal(body.chatid, "tgcnewchat");
|
||||
assert.equal(body.userlist.length, 2);
|
||||
assert.ok(body.name.length <= 20, "chat name truncated to 20 chars");
|
||||
});
|
||||
@@ -925,6 +1081,16 @@ test("createAppChat rejects fewer than 2 members and oversized chatid", async ()
|
||||
assert.equal(calls.length, 0, "no API calls for invalid payload");
|
||||
});
|
||||
|
||||
test("createAppChat treats chatid existed (86215) as success", async () => {
|
||||
const { fetchImpl } = fakeAppChat({ createExisted: true });
|
||||
const result = await createAppChat(
|
||||
resolveWecomConfig(baseBindings),
|
||||
{ name: "【总群】项目一", userlist: ["a", "b"], chatid: "pgcproj1" },
|
||||
fetchImpl,
|
||||
);
|
||||
assert.equal(result.chatid, "pgcproj1");
|
||||
});
|
||||
|
||||
test("updateAppChat adds and removes members", async () => {
|
||||
const { calls, fetchImpl, chats } = fakeAppChat();
|
||||
await updateAppChat(
|
||||
@@ -962,7 +1128,7 @@ test("getAppChat returns member list or null on error", async () => {
|
||||
assert.equal(info.chatId, "tgc_task-existing");
|
||||
assert.deepEqual(info.userlist, ["owner-a"]);
|
||||
assert.equal(
|
||||
await getAppChat(resolveWecomConfig(baseBindings), "tgc_task-missing", fetchImpl),
|
||||
await getAppChat(resolveWecomConfig(baseBindings), "tgcmissingchat", fetchImpl),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -988,7 +1154,7 @@ test("sendAppChatMessage throws WecomClientError when provider errors", async ()
|
||||
() =>
|
||||
sendAppChatMessage(
|
||||
resolveWecomConfig(baseBindings),
|
||||
{ chatid: "tgc_task-missing", text: "hello" },
|
||||
{ chatid: "tgcmissingchat", text: "hello" },
|
||||
fetchImpl,
|
||||
),
|
||||
(error) => {
|
||||
|
||||
Reference in New Issue
Block a user