Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ WECHAT_WORKER_CLAIM_INTERVAL_MS=1000
# Delay between sentence-sized WeChat bubbles; use 0 in tests.
WECHAT_BUBBLE_DELAY_MS=200
WECHAT_TURN_BATCH_WINDOW_MS=1200
# First working-status bubble, then the gap between later status bubbles.
WECHAT_TURN_PROGRESS_DELAY_MS=1500
WECHAT_TURN_PROGRESS_INTERVAL_MS=5000
WECHAT_ILINK_CDN_BASE_URL=https://novac2c.cdn.weixin.qq.com/c2c
# AdventureX V1 clearing-tick matching. API and intelligence-worker must use the same value.
ADVENTUREX_MATCHING_V1=false
Expand Down
74 changes: 73 additions & 1 deletion apps/api/src/wechat-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ describe("WeChat one-time QR onboarding", () => {
it("allows only the roadshow account to bypass the public QR creation limit", async () => {
const roadshowUserId = randomUUID();
const otherUserId = randomUUID();
const { app } = await setup(
const { app, wechatStore } = await setup(
[],
undefined,
undefined,
Expand Down Expand Up @@ -203,6 +203,10 @@ describe("WeChat one-time QR onboarding", () => {
expect(first.statusCode).toBe(201);
expect(second.statusCode).toBe(201);
expect(first.json().sessionId).not.toBe(second.json().sessionId);
expect(await wechatStore.getWechatSession(first.json().sessionId))
.toMatchObject({ requestedUserId: null });
expect(await wechatStore.getWechatSession(second.json().sessionId))
.toMatchObject({ requestedUserId: null });
});

it("creates a profile and reuses it when the same WeChat identity reconnects", async () => {
Expand Down Expand Up @@ -286,6 +290,65 @@ describe("WeChat one-time QR onboarding", () => {
expect(verifyAccessToken).not.toHaveBeenCalled();
});

it("reuses the scanner profile instead of binding it to the roadshow operator", async () => {
const roadshowUserId = randomUUID();
const owner = "wechat-owner-roadshow-reconnect";
const confirmed = {
status: "confirmed",
bot_token: "bot-secret",
ilink_bot_id: "bot-roadshow-1",
baseurl: "https://ilink-api.example.com",
ilink_user_id: owner
};
const { app, store } = await setup(
[confirmed, {
...confirmed,
bot_token: "rotated-secret",
ilink_bot_id: "bot-roadshow-2"
}],
undefined,
undefined,
undefined,
{
rapidQrTokens: ["roadshow-token"],
userByToken: { "roadshow-token": roadshowUserId }
}
);

const firstCreated = await app.inject({
method: "POST",
url: "/wechat/connect/sessions",
payload: {}
});
const first = firstCreated.json();
const firstActivated = await app.inject({
method: "GET",
url: `/wechat/connect/sessions/${first.sessionId}`,
headers: { "x-wechat-session-token": first.sessionToken }
});
expect(firstActivated.json()).toMatchObject({ status: "active" });
const scannerUserId = (await store.resolveChannelIdentity("wechat", owner))!.userId;
expect(scannerUserId).not.toBe(roadshowUserId);

const secondCreated = await app.inject({
method: "POST",
url: "/wechat/connect/sessions/demo",
headers: { authorization: "Bearer roadshow-token" },
payload: {}
});
const second = secondCreated.json();
const secondActivated = await app.inject({
method: "GET",
url: `/wechat/connect/sessions/${second.sessionId}`,
headers: { "x-wechat-session-token": second.sessionToken }
});

expect(secondActivated.statusCode).toBe(200);
expect(secondActivated.json()).toMatchObject({ status: "active" });
expect(await store.resolveChannelIdentity("wechat", owner))
.toMatchObject({ userId: scannerUserId });
});

it("claims activation once when concurrent QR polls both observe confirmation", async () => {
const confirmed = {
status: "confirmed",
Expand Down Expand Up @@ -874,6 +937,15 @@ describe("WeChat one-time QR onboarding", () => {
});

expect(conflict.statusCode).toBe(409);
expect(await app.inject({
method: "GET",
url: `/wechat/connect/sessions/${second.sessionId}`,
headers: { "x-wechat-session-token": second.sessionToken }
}).then((response) => response.json())).toMatchObject({
status: "failed",
errorCode: "profile_binding_conflict",
errorMessage: "该微信已关联其他 TOMEET profile"
});
});

it("limits public QR creation to thirty attempts per ten minutes", async () => {
Expand Down
42 changes: 29 additions & 13 deletions apps/api/src/wechat-routes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { randomBytes, randomUUID } from "node:crypto";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import type { WechatConnectionStore } from "@tomeet/data";
import { StoreConflictError, type WechatConnectionStore } from "@tomeet/data";
import {
CredentialCipher,
hashSessionToken,
Expand Down Expand Up @@ -173,17 +173,31 @@ async function activateSession(
const shouldSendActivationWelcome = onActivated && isNewWechatIdentity
? await isNewWechatIdentity(credentials.ilinkUserId)
: true;
const activation = await runtime.store.activateWechatSession({
sessionId: session.id,
newUserId: randomUUID(),
ownerIlinkUserId: credentials.ilinkUserId,
ilinkBotId: credentials.ilinkBotId,
botTokenCiphertext: runtime.cipher.encrypt(
credentials.botToken,
`wechat-connection:${credentials.ilinkUserId}`
),
baseUrl
});
let activation: Awaited<ReturnType<WechatConnectionStore["activateWechatSession"]>>;
try {
activation = await runtime.store.activateWechatSession({
sessionId: session.id,
newUserId: randomUUID(),
ownerIlinkUserId: credentials.ilinkUserId,
ilinkBotId: credentials.ilinkBotId,
botTokenCiphertext: runtime.cipher.encrypt(
credentials.botToken,
`wechat-connection:${credentials.ilinkUserId}`
),
baseUrl
});
} catch (error) {
if (error instanceof StoreConflictError) {
await runtime.store.updateWechatSession(session.id, {
status: "failed",
errorCode: "profile_binding_conflict",
errorMessage: error.message
}, {
ifStatusIn: NON_TERMINAL_SESSION_STATUSES
});
}
throw error;
}
if (
activation.session.userId
&& onActivated
Expand Down Expand Up @@ -481,7 +495,9 @@ export function registerWechatRoutes(
message: "当前账户不能使用路演二维码模式"
});
}
const created = await createSession(request.authUserId);
// The bearer token authorizes the roadshow operator to create unlimited
// QR codes. It does not identify the visitor who scans the displayed QR.
const created = await createSession();
if (!created) {
return reply.code(503).send({
error: "wechat_connect_disabled",
Expand Down
18 changes: 17 additions & 1 deletion apps/wechat-ilink-worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ const turnBatchWindowMs = integerEnvironment(
100,
10_000
);
const turnProgressDelayMs = integerEnvironment(
"WECHAT_TURN_PROGRESS_DELAY_MS",
1500,
0,
60_000
);
const turnProgressIntervalMs = integerEnvironment(
"WECHAT_TURN_PROGRESS_INTERVAL_MS",
5000,
250,
60_000
);
const claimIntervalMs = integerEnvironment(
"WECHAT_WORKER_CLAIM_INTERVAL_MS",
1000,
Expand Down Expand Up @@ -108,6 +120,8 @@ async function monitorConnection(connection: WechatConnection): Promise<void> {
tomeet,
bubbleDelayMs,
turnBatchWindowMs,
turnProgressDelayMs,
turnProgressIntervalMs,
imageCdnBaseUrl: process.env.WECHAT_ILINK_CDN_BASE_URL
});
}
Expand Down Expand Up @@ -165,7 +179,9 @@ async function run(): Promise<void> {
concurrency,
outboundConcurrency,
bubbleDelayMs,
turnBatchWindowMs
turnBatchWindowMs,
turnProgressDelayMs,
turnProgressIntervalMs
}));
while (!abortController.signal.aborted) {
const capacity = concurrency - active.size;
Expand Down
Loading
Loading