mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +08:00
feat(feishu): interactive card menus and session/workspace list cards
- /m (or /menu) opens a card menu; /sessionlist and /workspacelist render cards with bind/switch buttons; number replies stay usable as a fallback when the app does not subscribe card.action.trigger. - card.action.trigger callbacks validate the operator's open_id against the allowed senders: group members outside the allowlist can never drive binding, workspace switches or other card actions. - Session-list pagination uses page numbers everywhere (buttons carry sessions:<page>), fixing the previous double page-size scaling that skipped pages past 20 sessions. - Bind/workspace failures map to safe user-facing messages instead of raw error details. - Apps registered after this change subscribe card.action.trigger during the scan flow.
This commit is contained in:
parent
832bd539a2
commit
6366404c42
9 changed files with 642 additions and 137 deletions
|
|
@ -1,6 +1,9 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Readable } from 'node:stream';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { FeishuHarnessBridge } from '../../../src/channels/feishu/bridge.mjs';
|
||||
import { DEFAULT_IMAGE_PROMPT } from '../../../src/channels/shared/image-prompt.mjs';
|
||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||
|
|
@ -1871,3 +1874,143 @@ test('bridge does not expose internal error details in a Feishu failure reply',
|
|||
assert.doesNotMatch(sent[0], /secret-shaped-internal-detail|private\/path/);
|
||||
assert.equal(status.lastError, 'secret-shaped-internal-detail /private/path');
|
||||
});
|
||||
|
||||
// ── Interactive cards: menus, session lists, workspace lists ───────────────
|
||||
|
||||
function cardClient(onSend) {
|
||||
let sequence = 0;
|
||||
return {
|
||||
im: { v1: { message: { create: async (request) => {
|
||||
const outgoing = {
|
||||
chatId: request.data.receive_id,
|
||||
msgType: request.data.msg_type,
|
||||
content: request.data.msg_type === 'interactive'
|
||||
? JSON.parse(request.data.content)
|
||||
: request.data.content,
|
||||
};
|
||||
await onSend(outgoing);
|
||||
sequence += 1;
|
||||
return { code: 0, data: { message_id: `om_card_${sequence}` } };
|
||||
} } } },
|
||||
};
|
||||
}
|
||||
|
||||
function cardActionEvent(messageId, action, operatorOpenId) {
|
||||
return {
|
||||
operator: { operator_id: { open_id: operatorOpenId } },
|
||||
action: { value: { action } },
|
||||
context: { open_message_id: messageId },
|
||||
};
|
||||
}
|
||||
|
||||
function useActionsFromCard(content) {
|
||||
const values = [];
|
||||
for (const element of content.body.elements) {
|
||||
if (element.tag === 'button' && typeof element.value?.action === 'string' && element.value.action.startsWith('use:')) {
|
||||
values.push(element.value.action.slice('use:'.length));
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function sessionsHarness(count) {
|
||||
const workspace = join(tmpdir(), 'dsh-im-card-test-work');
|
||||
mkdirSync(workspace, { recursive: true });
|
||||
const sessions = Array.from({ length: count }, (_, index) => ({
|
||||
sessionId: `session-${String(index + 1).padStart(2, '0')}`,
|
||||
title: `Session ${index + 1}`,
|
||||
}));
|
||||
return {
|
||||
ensureRunning: async () => true,
|
||||
currentWorkspace: () => workspace,
|
||||
listWorkspaceSessions: async () => ({ workspace, sessions }),
|
||||
listWorkspaces: async () => [workspace],
|
||||
bindWorkspaceSession: async (_key, sessionId) => ({ sessionId, title: `Session ${sessionId}` }),
|
||||
switchWorkspace: async (path) => path,
|
||||
};
|
||||
}
|
||||
|
||||
test('card buttons from an unallowed sender are ignored', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: cardClient(async ({ chatId, msgType, content }) => {
|
||||
sent.push({ chatId, msgType, content });
|
||||
}),
|
||||
channel: {},
|
||||
harness: sessionsHarness(3),
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||
});
|
||||
|
||||
await bridge.accept(event('menu-open', '/m', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.waitForIdle();
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(fixture.sessions.size, 0);
|
||||
|
||||
// A group member outside the allowlist clicks "new session" on the card.
|
||||
await bridge.onCardAction(cardActionEvent('om_card_1', 'new', 'ou_evil'));
|
||||
await bridge.waitForIdle();
|
||||
assert.equal(fixture.sessions.size, 0, 'unallowed card operator must not act');
|
||||
});
|
||||
|
||||
test('card buttons from an allowed sender work', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: cardClient(async ({ chatId, msgType, content }) => {
|
||||
sent.push({ chatId, msgType, content });
|
||||
}),
|
||||
channel: {},
|
||||
harness: sessionsHarness(3),
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||
});
|
||||
|
||||
await bridge.accept(event('menu-open-2', '/m', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.waitForIdle();
|
||||
assert.equal(sent.length, 1);
|
||||
|
||||
await bridge.onCardAction(cardActionEvent('om_card_1', 'new', 'ou_owner'));
|
||||
await bridge.waitForIdle();
|
||||
assert.equal(sent.length, 2, 'allowed operator click should send a reply');
|
||||
});
|
||||
|
||||
function cards(messages) { return messages.filter((m) => m.msgType === 'interactive'); }
|
||||
|
||||
test('session list paginates by page number across 25 sessions', async () => {
|
||||
const fixture = stateFixture();
|
||||
const sent = [];
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: cardClient(async ({ chatId, msgType, content }) => {
|
||||
sent.push({ chatId, msgType, content });
|
||||
}),
|
||||
channel: {},
|
||||
harness: sessionsHarness(25),
|
||||
state: fixture.state,
|
||||
status: bridgeStatus(),
|
||||
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||
});
|
||||
|
||||
await bridge.accept(event('sessions-open', '/sessionlist', { senderOpenId: 'ou_owner' }));
|
||||
await bridge.waitForIdle();
|
||||
assert.equal(cards(sent).length, 1);
|
||||
const page0 = cards(sent).at(-1).content;
|
||||
assert.equal(useActionsFromCard(page0).length, 10);
|
||||
assert.equal(useActionsFromCard(page0)[0], 'session-01');
|
||||
|
||||
// Button on page 0 asks for page 2 (zero-based page number).
|
||||
await bridge.onCardAction(cardActionEvent('om_card_1', 'sessions:2', 'ou_owner'));
|
||||
await bridge.waitForIdle();
|
||||
const page2 = cards(sent).at(-1).content;
|
||||
assert.equal(useActionsFromCard(page2).length, 5);
|
||||
assert.equal(useActionsFromCard(page2)[0], 'session-21', 'page 2 must start at the 21st session (no double page scaling)');
|
||||
|
||||
await bridge.onCardAction(cardActionEvent('om_card_2', 'sessions:1', 'ou_owner'));
|
||||
await bridge.waitForIdle();
|
||||
const page1 = cards(sent).at(-1).content;
|
||||
assert.equal(useActionsFromCard(page1).length, 10);
|
||||
assert.equal(useActionsFromCard(page1)[0], 'session-11');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ test('QR success stores the secret off-config and becomes immediately chat-ready
|
|||
await flush();
|
||||
assert.equal(fx.getSdkOptions().createOnly, true);
|
||||
assert.equal(fx.getSdkOptions().addons.preset, false);
|
||||
assert.deepEqual(fx.getSdkOptions().addons.events.items.tenant, ['im.message.receive_v1']);
|
||||
assert.deepEqual(fx.getSdkOptions().addons.events.items.tenant, ['im.message.receive_v1', 'card.action.trigger']);
|
||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message.p2p_msg:readonly'));
|
||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message:readonly'));
|
||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message:send_as_bot'));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue