mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 13:40:37 +00:00
* feat(channels): generalize IM group targets and add task/goal run notifications
- Generalize proactive group notification capability (proactive_group_notifications), binding validation, and scheduled task group selection across Feishu, QQ, and WeCom while keeping WeChat personal-only.
- Add QQ group active notification delivery via POST /v2/groups/{group_openid}/messages (msg_type=2) and WeCom group active notification delivery via WS Client.sendMessage(chatid, body) with bot_id:group:chatid workspace isolation.
- Generalize notification outbox sources via migration 0043_runtime_im_notices (source_kind, source_key, run_id, nullable task_id/task_run_id) with backward-compatible SQLite/PostgreSQL upgrades.
- Add opt-in ordinary task & goal run IM notifications (run_completed, run_failed, human_input_requested) on the shared outbox, delivery worker, renderer, and settings UI.
* fix(notifications): address review feedback and group/runtime notification edge cases
* test(migrations): update CURRENT_HEAD to 0043_runtime_im_notices across migration chain tests
* fix(channels): preserve group binding names and legacy authorization
Carry owner-chosen group names through existing connect state, keep private bindings out of legacy group fallback, and restore completion metadata retries. Cover real binding and worker finalization paths and synchronize notification docs.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
282 lines
8.0 KiB
TypeScript
282 lines
8.0 KiB
TypeScript
import { beforeEach, describe, expect, test, rs } from "@rstest/core";
|
|
|
|
rs.mock("@/core/api/fetcher", () => ({
|
|
fetch: rs.fn(),
|
|
}));
|
|
|
|
rs.mock("@/core/config", () => ({
|
|
getBackendBaseURL: () => "/backend",
|
|
}));
|
|
|
|
import { fetch as fetcher } from "@/core/api/fetcher";
|
|
import {
|
|
cancelWechatQRLogin,
|
|
pollWechatQRLogin,
|
|
startWechatQRLogin,
|
|
configureChannelProvider,
|
|
connectChannelProvider,
|
|
disconnectChannelConnection,
|
|
disconnectChannelProvider,
|
|
listChannelConnections,
|
|
listChannelProviders,
|
|
} from "@/core/channels/api";
|
|
|
|
const mockedFetch = rs.mocked(fetcher);
|
|
|
|
function jsonResponse(status: number, body: unknown): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
statusText: status >= 400 ? "Bad Request" : "OK",
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockedFetch.mockReset();
|
|
});
|
|
|
|
describe("channels api", () => {
|
|
test("sends an owner-chosen group name with the binding-code request", async () => {
|
|
mockedFetch.mockResolvedValueOnce(
|
|
jsonResponse(200, { provider: "qq", code: "code", expires_in: 600 }),
|
|
);
|
|
await connectChannelProvider("qq", { groupName: "Release team" });
|
|
expect(mockedFetch).toHaveBeenCalledWith(
|
|
"/backend/api/channels/qq/connect",
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ group_name: "Release team" }),
|
|
},
|
|
);
|
|
});
|
|
|
|
test("loads provider catalog", async () => {
|
|
mockedFetch.mockResolvedValueOnce(
|
|
jsonResponse(200, {
|
|
enabled: true,
|
|
providers: [
|
|
{
|
|
provider: "telegram",
|
|
display_name: "Telegram",
|
|
enabled: true,
|
|
configured: true,
|
|
auth_mode: "deep_link",
|
|
connection_status: "not_connected",
|
|
credential_values: {
|
|
bot_token: "********",
|
|
bot_username: "deerflow_bot",
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
await expect(listChannelProviders()).resolves.toMatchObject({
|
|
enabled: true,
|
|
providers: [
|
|
{
|
|
provider: "telegram",
|
|
display_name: "Telegram",
|
|
credential_values: {
|
|
bot_token: "********",
|
|
bot_username: "deerflow_bot",
|
|
},
|
|
},
|
|
],
|
|
});
|
|
expect(mockedFetch).toHaveBeenCalledWith("/backend/api/channels/providers");
|
|
});
|
|
|
|
test("loads current user's connections", async () => {
|
|
mockedFetch.mockResolvedValueOnce(
|
|
jsonResponse(200, {
|
|
connections: [
|
|
{
|
|
id: "connection-1",
|
|
provider: "telegram",
|
|
status: "connected",
|
|
external_account_name: "Alice",
|
|
scopes: [],
|
|
metadata: {},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
await expect(listChannelConnections()).resolves.toMatchObject([
|
|
{ id: "connection-1", provider: "telegram", status: "connected" },
|
|
]);
|
|
expect(mockedFetch).toHaveBeenCalledWith(
|
|
"/backend/api/channels/connections",
|
|
);
|
|
});
|
|
|
|
test("starts a provider connection flow", async () => {
|
|
mockedFetch.mockResolvedValueOnce(
|
|
jsonResponse(200, {
|
|
provider: "telegram",
|
|
mode: "deep_link",
|
|
url: "https://t.me/deerflow_bot?start=state",
|
|
code: "state",
|
|
instruction: "Send /start state to the DeerFlow Telegram bot.",
|
|
expires_in: 600,
|
|
}),
|
|
);
|
|
|
|
await expect(connectChannelProvider("telegram")).resolves.toMatchObject({
|
|
provider: "telegram",
|
|
url: "https://t.me/deerflow_bot?start=state",
|
|
instruction: "Send /start state to the DeerFlow Telegram bot.",
|
|
});
|
|
expect(mockedFetch).toHaveBeenCalledWith(
|
|
"/backend/api/channels/telegram/connect",
|
|
{ method: "POST" },
|
|
);
|
|
});
|
|
|
|
test("starts a binding-code connection flow", async () => {
|
|
mockedFetch.mockResolvedValueOnce(
|
|
jsonResponse(200, {
|
|
provider: "slack",
|
|
mode: "binding_code",
|
|
url: null,
|
|
code: "abc123",
|
|
instruction: "Send /connect abc123 to the DeerFlow Slack bot.",
|
|
expires_in: 600,
|
|
}),
|
|
);
|
|
|
|
await expect(connectChannelProvider("slack")).resolves.toMatchObject({
|
|
provider: "slack",
|
|
url: null,
|
|
code: "abc123",
|
|
instruction: "Send /connect abc123 to the DeerFlow Slack bot.",
|
|
});
|
|
});
|
|
|
|
test("submits runtime provider configuration", async () => {
|
|
mockedFetch.mockResolvedValueOnce(
|
|
jsonResponse(200, {
|
|
provider: "slack",
|
|
display_name: "Slack",
|
|
enabled: true,
|
|
configured: true,
|
|
connectable: true,
|
|
auth_mode: "binding_code",
|
|
connection_status: "not_connected",
|
|
}),
|
|
);
|
|
|
|
await expect(
|
|
configureChannelProvider("slack", {
|
|
bot_token: "xoxb-ui",
|
|
app_token: "xapp-ui",
|
|
}),
|
|
).resolves.toMatchObject({
|
|
provider: "slack",
|
|
configured: true,
|
|
connectable: true,
|
|
});
|
|
expect(mockedFetch).toHaveBeenCalledWith(
|
|
"/backend/api/channels/slack/runtime-config",
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
values: { bot_token: "xoxb-ui", app_token: "xapp-ui" },
|
|
}),
|
|
},
|
|
);
|
|
});
|
|
|
|
test("disconnects a channel connection", async () => {
|
|
mockedFetch.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
|
|
|
await expect(
|
|
disconnectChannelConnection("connection-1"),
|
|
).resolves.toBeUndefined();
|
|
expect(mockedFetch).toHaveBeenCalledWith(
|
|
"/backend/api/channels/connections/connection-1",
|
|
{ method: "DELETE" },
|
|
);
|
|
});
|
|
|
|
test("disconnects provider runtime configuration", async () => {
|
|
mockedFetch.mockResolvedValueOnce(
|
|
jsonResponse(200, {
|
|
provider: "slack",
|
|
display_name: "Slack",
|
|
enabled: true,
|
|
configured: false,
|
|
connectable: false,
|
|
auth_mode: "binding_code",
|
|
connection_status: "not_connected",
|
|
}),
|
|
);
|
|
|
|
await expect(disconnectChannelProvider("slack")).resolves.toMatchObject({
|
|
provider: "slack",
|
|
configured: false,
|
|
connection_status: "not_connected",
|
|
});
|
|
expect(mockedFetch).toHaveBeenCalledWith(
|
|
"/backend/api/channels/slack/runtime-config",
|
|
{ method: "DELETE" },
|
|
);
|
|
});
|
|
|
|
test("uses backend detail for failed requests", async () => {
|
|
mockedFetch.mockResolvedValueOnce(
|
|
jsonResponse(400, { detail: "Channel provider is not configured" }),
|
|
);
|
|
|
|
await expect(connectChannelProvider("slack")).rejects.toThrow(
|
|
"Channel provider is not configured",
|
|
);
|
|
});
|
|
});
|
|
|
|
test("WeChat QR requests use authenticated mutations and support cancellation", async () => {
|
|
const session = {
|
|
id: "qr/id",
|
|
status: "pending",
|
|
qrcode_content: "scan",
|
|
expires_in: 180,
|
|
provider: null,
|
|
};
|
|
mockedFetch.mockResolvedValueOnce(jsonResponse(200, session));
|
|
await expect(startWechatQRLogin()).resolves.toEqual(session);
|
|
expect(mockedFetch).toHaveBeenLastCalledWith(
|
|
"/backend/api/channels/wechat/qr-login",
|
|
{ method: "POST" },
|
|
);
|
|
const controller = new AbortController();
|
|
mockedFetch.mockResolvedValueOnce(jsonResponse(200, session));
|
|
await pollWechatQRLogin(session.id, controller.signal);
|
|
expect(mockedFetch).toHaveBeenLastCalledWith(
|
|
"/backend/api/channels/wechat/qr-login/qr%2Fid/poll",
|
|
{ method: "POST", signal: controller.signal },
|
|
);
|
|
mockedFetch.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
|
await cancelWechatQRLogin(session.id);
|
|
expect(mockedFetch).toHaveBeenLastCalledWith(
|
|
"/backend/api/channels/wechat/qr-login/qr%2Fid",
|
|
{ method: "DELETE" },
|
|
);
|
|
});
|
|
|
|
test("submits a pairing code in the request body, never the URL", async () => {
|
|
mockedFetch.mockResolvedValueOnce(jsonResponse(200, { status: "scanned" }));
|
|
await pollWechatQRLogin("session", undefined, "123456");
|
|
expect(mockedFetch).toHaveBeenCalledWith(
|
|
"/backend/api/channels/wechat/qr-login/session/poll",
|
|
{
|
|
method: "POST",
|
|
signal: undefined,
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ verify_code: "123456" }),
|
|
},
|
|
);
|
|
});
|