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
11 changes: 11 additions & 0 deletions changes/1031-lark-native-questions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
section: Added
---

## en

- A paired Lark management DM can answer native Bot questions through a private form, a quoted reply or `/answer`, with the original session staying the single authority and uncertain submissions never replayed ([#1031](https://github.com/BotHarness/DeepSeekBot/issues/1031)).

## zh

- 已配对的 Lark 管理私聊可通过私密表单、引用回复或 `/answer` 回答 Bot 的原生提问;答案始终以原会话为准,结果未确认的提交不会重放([#1031](https://github.com/BotHarness/DeepSeekBot/issues/1031))。
2 changes: 2 additions & 0 deletions docs/bot-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,8 @@ deepseekbot approval-routes <bot-id>
deepseekbot approval-route <bot-id> --pairing <pairing-id|none>
deepseekbot approval-test <bot-id>
deepseekbot approval-retry <bot-id> --delivery <delivery-id>
deepseekbot question-deliveries <bot-id>
deepseekbot question-retry <bot-id> --delivery <delivery-id>
deepseekbot failure-alerts <bot-id>
deepseekbot failure-alerts-set <bot-id> --enabled
deepseekbot failure-alerts-test <bot-id>
Expand Down
2 changes: 2 additions & 0 deletions docs/bot-cli.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,8 @@ deepseekbot approval-routes <bot-id>
deepseekbot approval-route <bot-id> --pairing <pairing-id|none>
deepseekbot approval-test <bot-id>
deepseekbot approval-retry <bot-id> --delivery <delivery-id>
deepseekbot question-deliveries <bot-id>
deepseekbot question-retry <bot-id> --delivery <delivery-id>
deepseekbot failure-alerts <bot-id>
deepseekbot failure-alerts-set <bot-id> --enabled
deepseekbot failure-alerts-test <bot-id>
Expand Down
24 changes: 24 additions & 0 deletions docs/dev/verification/lark-1031-native-questions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Lark native question qualification (#1031)

## Candidate and scope

A paired Lark management DM answers a native Bot question through a private form or an explicit text reply. The original session stays the one authority, and an unconfirmed submission is never replayed.

Provider: `34b53227d4dc6a1e32059a7489e4e0fe280aeeb4`, 425 runtime files. Core: this branch on DSH `0.2.1-rc.3`, with `deepseek-v4.1-flash` driving real model turns.

## Native qualification — 2026-11-03 JST

An isolated Host connected the dedicated `BotHarness Lark QA1` app, which is visible only to the test account. The app gained `im:chat:read`, `im:resource` and the long-connection `card.action.trigger` callback in published version 1.0.1. A `/pair` request from the real DM was approved with only the `answer` capability, then selected as the management route.

| Native test | Actual action in Lark | Result |
| -------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| Two-question form | Chose `Red` and typed free text for the second question | One resolution with the actor recorded, the Bot continued, card updated to Answered |
| Single question, `/answer` | Sent `/answer <reference> Tuesday` in the DM | One resolution, the Bot replied `LARK_TEXT_OK Tuesday`, card updated to Answered |

Delivery records show `sent`, `accepted` and `updated` for both. Web shows the same answers, and the Management notifications panel lists both in light and dark.

## Not covered natively

Quoted-reply answers, cancelled or expired cards, a Web and Lark answer race, restart, and unconfirmed-submission repair have automated coverage only. The form's duplicated text in the first test came from the test typing it twice.

The QA binding was removed and the Host stopped. Screenshots are local evidence in the task's verify folder.
2 changes: 1 addition & 1 deletion docs/lark-connection.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ private-origin check is retained; a group pairing does not create a management d
Removing a capability or revoking/reassigning the person refuses delayed decisions. If
reassignment invalidates the route, explicitly select/save its new current destination again.
Legacy `/pair` management grants remain independent and do not enable ordinary chat by themselves.
Native Questions, group execution-approval destinations and pairing-code issuance are separate work.
Group execution-approval destinations and pairing-code issuance are separate work. A management DM with the **Answer formal questions** capability receives native questions as a private form and can also answer with a quoted reply or `/answer <reference> <text>`; the original session accepts one answer, and an unconfirmed submission is never replayed ([#1031](https://github.com/BotHarness/DeepSeekBot/issues/1031)). Qualification is recorded in [the verification note](dev/verification/lark-1031-native-questions.md).

This slice supports Lark private **Allow once** and **Reject** cards through a qualified Provider. The #1033 source candidate adds the separately confirmed exact-rule path below. Group execution approvals, native question forms and non-blocking native waits are separate slices. The released dsh-im package number alone does not imply card capability; an unavailable Provider remains unavailable.

Expand Down
3 changes: 3 additions & 0 deletions packages/client/src/client/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
setApprovalRoute,
testApprovalRoute,
retryApprovalNotification,
retryQuestionNotification,
setFailureAlerts,
testFailureAlerts,
retryFailureAlerts,
Expand Down Expand Up @@ -475,6 +476,7 @@ export interface BridgeActions {
approvalRoute(slug: string, pairingId: string | null, expectedRevision: number): Promise<void>;
approvalTest(slug: string): Promise<void>;
approvalRetry(slug: string, id: string): Promise<void>;
questionRetry(slug: string, id: string): Promise<void>;
failureAlertsSet(slug: string, enabled: boolean, expectedRevision: number): Promise<void>;
failureAlertsTest(slug: string): Promise<void>;
failureAlertsRetry(slug: string, id: string): Promise<void>;
Expand Down Expand Up @@ -2894,6 +2896,7 @@ export function createActions(
setApprovalRoute(call, slug, pairingId, expectedRevision),
approvalTest: (slug) => testApprovalRoute(call, slug),
approvalRetry: (slug, id) => retryApprovalNotification(call, slug, id),
questionRetry: (slug, id) => retryQuestionNotification(call, slug, id),
failureAlertsSet: (slug, enabled, expectedRevision) =>
setFailureAlerts(call, slug, enabled, expectedRevision),
failureAlertsTest: (slug) => testFailureAlerts(call, slug),
Expand Down
8 changes: 8 additions & 0 deletions packages/client/src/client/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5947,6 +5947,14 @@ export async function retryApprovalNotification(
await unwrap(call, 'approvalRetry', { slug, id });
}

export async function retryQuestionNotification(
call: BridgeCall,
slug: string,
id: string,
): Promise<void> {
await unwrap(call, 'questionRetry', { slug, id });
}

export async function setFailureAlerts(
call: BridgeCall,
slug: string,
Expand Down
8 changes: 8 additions & 0 deletions packages/client/src/client/external-entries.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { QuestionNotifications } from './question-notifications.js';
import { useState, type ReactElement } from 'react';
import { Button, Tag } from '@deepseek-ai/dsh-client-ui-primitives';
import { ReachablePostDialog } from './reachable-post.js';
Expand Down Expand Up @@ -232,6 +233,13 @@ function IdentitiesForBot({
retry: (id) => operate(() => actions.failureAlertsRetry(slug, id), 'approval'),
}}
/>
<QuestionNotifications
deliveries={snapshot?.questions ?? []}
busy={busy}
t={t}
repair={(id) => operate(() => actions.questionRetry(slug, id), 'approval')}
openSession={actions.openSession}
/>
</PanelDialog>
) : null}
</div>
Expand Down
36 changes: 34 additions & 2 deletions packages/client/src/client/locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,21 @@ export const zh = {
'failureAlerts.state.failed': '确定未发送',
'failureAlerts.state.unknown-outcome': '结果未知',
'failureAlerts.state.cancelled': '已取消',
'questionIm.title': 'Lark 提问通知',
'questionIm.hint':
'同一管理私聊可回答原生提问,需要单独授予“回答”能力。原会话接收答案后才会显示已回答。',
'questionIm.empty': '暂无提问通知。',
'questionIm.reconcile': '核对原提问',
'questionIm.repair': '修复通知',
'questionIm.status.pending': '原提问等待回答',
'questionIm.status.answered': '已回答',
'questionIm.status.cancelled': '已取消',
'questionIm.status.expired': '已失效',
'questionIm.submission.none': '尚未提交答案',
'questionIm.submission.submitting': '正在提交答案',
'questionIm.submission.accepted': '原会话已接受答案',
'questionIm.submission.refused': '答案未被接受,原提问继续等待',
'questionIm.submission.unknown-outcome': '答案结果未确认;核对后再继续,系统不会重放答案。',
'approvalIm.title': '管理通知',
'approvalIm.configuration': '通知设置',
'approvalIm.platform': 'Lark 私聊',
Expand All @@ -827,7 +842,7 @@ export const zh = {
'approvalIm.refresh': '刷新通知',
'approvalIm.destination': '管理私聊',
'approvalIm.off': '关闭自动通知',
'approvalIm.empty': '先在私聊发起 /pair,再在 Web 授予批准或拒绝能力。',
'approvalIm.empty': '先在私聊发起 /pair,再在 Web 授予批准、拒绝或回答能力。',
'approvalIm.save': '保存目的地',
'approvalIm.test': '发送测试卡片',
'approvalIm.once':
Expand Down Expand Up @@ -5301,6 +5316,22 @@ export const en = {
'failureAlerts.state.failed': 'Known unsent',
'failureAlerts.state.unknown-outcome': 'Unknown outcome',
'failureAlerts.state.cancelled': 'Cancelled',
'questionIm.title': 'Lark question notifications',
'questionIm.hint':
'The same management DM can answer native questions with a separate answer capability. Answered appears only after the original session accepts the answer.',
'questionIm.empty': 'No question notifications yet.',
'questionIm.reconcile': 'Check original question',
'questionIm.repair': 'Repair notification',
'questionIm.status.pending': 'Original question awaits an answer',
'questionIm.status.answered': 'Answered',
'questionIm.status.cancelled': 'Cancelled',
'questionIm.status.expired': 'Expired',
'questionIm.submission.none': 'No answer submitted',
'questionIm.submission.submitting': 'Submitting answer',
'questionIm.submission.accepted': 'Original session accepted the answer',
'questionIm.submission.refused': 'Answer not accepted; original question still waits',
'questionIm.submission.unknown-outcome':
'Answer unconfirmed; check before continuing. The answer will not be replayed.',
'approvalIm.title': 'Management notifications',
'approvalIm.configuration': 'Notification settings',
'approvalIm.platform': 'Lark DM',
Expand All @@ -5322,7 +5353,8 @@ export const en = {
'approvalIm.refresh': 'Refresh notifications',
'approvalIm.destination': 'Management DM',
'approvalIm.off': 'Automatic notifications off',
'approvalIm.empty': 'Send /pair in the Bot DM, then grant approve or reject capability in Web.',
'approvalIm.empty':
'Send /pair in the Bot DM, then grant approve, reject or answer capability in Web.',
'approvalIm.save': 'Save destination',
'approvalIm.test': 'Send test card',
'approvalIm.once':
Expand Down
70 changes: 70 additions & 0 deletions packages/client/src/client/question-notifications.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { ReactElement } from 'react';
import { Button, Tag } from '@deepseek-ai/dsh-client-ui-primitives';
import type { QuestionDelivery } from '../../../core/src/messaging/question-messaging.js';
import type { BotHarnessTranslate } from './locale.js';

export function QuestionNotifications({
deliveries,
busy,
repair,
openSession,
t,
}: {
deliveries: QuestionDelivery[];
busy: boolean;
repair(id: string): Promise<void>;
openSession(id: string): void;
t: BotHarnessTranslate;
}): ReactElement {
return (
<section className="bh-im-pairing" aria-label={t('questionIm.title')}>
<strong>{t('questionIm.title')}</strong>
<p className="bh-muted">{t('questionIm.hint')}</p>
{deliveries.length === 0 ? <p className="bh-muted">{t('questionIm.empty')}</p> : null}
{deliveries.map((delivery) => {
const canRepair =
(delivery.submission === 'unknown-outcome' &&
['pending', 'answered'].includes(delivery.status)) ||
(delivery.delivery === 'failed' &&
delivery.attempts < 3 &&
delivery.status === 'pending') ||
(delivery.receipt !== undefined && delivery.update === 'failed');
return (
<article key={delivery.id} className="bh-im-pairing-request">
<div className="bh-im-actions">
<strong>{delivery.reference}</strong>
<Tag>{t(`questionIm.status.${delivery.status}`)}</Tag>
<Tag>{t(`approvalIm.delivery.${delivery.delivery}`)}</Tag>
</div>
<p role="status">{t(`questionIm.submission.${delivery.submission}`)}</p>
{delivery.update === 'unknown-outcome' || delivery.update === 'failed' ? (
<p role="status">{t('approvalIm.updateUnconfirmed')}</p>
) : null}
{delivery.delivery === 'unknown-outcome' ? (
<p role="status">{t('approvalIm.unknownHint')}</p>
) : null}
<div className="bh-im-actions">
<Button size="sm" variant="toolbar" onClick={() => openSession(delivery.sessionId)}>
{t('approvalIm.openSession')}
</Button>
{canRepair ? (
<Button
size="sm"
variant="toolbar"
disabled={busy}
onClick={() => void repair(delivery.id)}
>
{t(
delivery.submission === 'unknown-outcome'
? 'questionIm.reconcile'
: 'questionIm.repair',
)}
</Button>
) : null}
</div>
</article>
);
})}
</section>
);
}
1 change: 1 addition & 0 deletions packages/client/test/bot-sidebar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@
approvalRoute: vi.fn(async () => {}),
approvalTest: vi.fn(async () => {}),
approvalRetry: vi.fn(async () => {}),
questionRetry: vi.fn(),
failureAlertsSet: vi.fn(async () => {}),
failureAlertsTest: vi.fn(async () => {}),
failureAlertsRetry: vi.fn(async () => {}),
Expand Down Expand Up @@ -1336,7 +1337,7 @@
]);
expect(menu.items[0]?.['disabled']).toBeUndefined();
expect(
(menu.items[0]?.['submenu'] as Array<Record<string, unknown>>).map((item) => [

Check warning on line 1340 in packages/client/test/bot-sidebar.test.ts

View workflow job for this annotation

GitHub Actions / prepare

eslint(no-unsafe-optional-chaining)

packages/client/test/bot-sidebar.test.ts:1340:8: Unsafe usage of optional chaining
item['id'],
item['label'],
]),
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/bots/cli-messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,14 @@ export const MESSAGING_COMMANDS = [
command: 'approval-retry',
description: 'retry one failed IM approval delivery',
},
{
command: 'question-retry',
description: 'repair one IM question notification or unconfirmed answer',
},
{
command: 'question-deliveries',
description: "read a Bot's IM question notifications",
},
{
command: 'failure-alerts',
description: "read a Bot's IM failure-alert setting and deliveries",
Expand Down Expand Up @@ -270,6 +278,7 @@ export async function prepareMessaging(
case 'channel-bridge':
case 'approval-routes':
case 'approval-test':
case 'question-deliveries':
case 'failure-alerts':
case 'failure-alerts-test':
break;
Expand Down Expand Up @@ -324,6 +333,7 @@ export async function prepareMessaging(
if (values.pairing !== 'none') uuid(values.pairing, '--pairing');
break;
case 'approval-retry':
case 'question-retry':
case 'failure-alerts-retry':
uuid(values.delivery, '--delivery');
break;
Expand Down Expand Up @@ -528,6 +538,13 @@ export async function runMessaging(operation: MessagingOperation, rpc: Rpc): Pro
slug: target,
id: values.delivery!.trim(),
});
case 'question-retry':
return rpc('questionRetry', {
slug: target,
id: values.delivery!.trim(),
});
case 'question-deliveries':
return { deliveries: (await snapshot())['questions'] ?? [] };
case 'failure-alerts':
return record((await snapshot())['failureAlerts']);
case 'failure-alerts-set': {
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/bridge/methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,7 @@ export interface BridgeMethods {
): Promise<
BridgeResult<{ delivery: import('../messaging/approval-messaging.js').ApprovalDelivery }>
>;
questionRetry(payload: unknown): Promise<BridgeResult<{ updated: true }>>;
approvalRetry(
payload: unknown,
): Promise<
Expand Down Expand Up @@ -2255,6 +2256,17 @@ export function createBridgeMethods(deps: BridgeMethodsDeps): BridgeMethods {
delivery: await service.approvals.test(input.data.slug),
}));
},
questionRetry(payload) {
const input = z
.object({ slug: z.string().min(1), id: z.string().uuid() })
.strict()
.safeParse(payload);
if (!input.success) return Promise.resolve(invalidInput('Invalid question notification'));
return messagingCall(async (service) => {
await service.questions.retry(input.data.slug, input.data.id);
return { updated: true as const };
});
},
approvalRetry(payload) {
const input = z
.object({ slug: z.string().min(1), id: z.string().uuid() })
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/bridge/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,9 @@ export class BotharnessBridgeService extends TypertRemoteService {
): Promise<{ delivery: import('../messaging/approval-messaging.js').ApprovalDelivery }> {
return unwrapAsync(this.methods.approvalTest({ slug }));
}
questionRetry(slug: string, id: string): Promise<{ updated: true }> {
return unwrapAsync(this.methods.questionRetry({ slug, id }));
}
approvalRetry(
slug: string,
id: string,
Expand Down Expand Up @@ -2390,6 +2393,7 @@ markRemoteMethods(BotharnessBridgeService.prototype, [
'approvalRoute',
'approvalTest',
'approvalRetry',
'questionRetry',
'failureAlertsSet',
'failureAlertsTest',
'failureAlertsRetry',
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/channels/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,11 @@ export function isChannelMessage(value: unknown): value is ChannelMessage {
if (questionResolution !== undefined) {
if (typeof questionResolution !== 'object' || questionResolution === null) return false;
const resolution = questionResolution as Record<string, unknown>;
if (
resolution['actor'] !== undefined &&
(resolution['state'] !== 'answered' || !parseToolApprovalActor(resolution['actor']))
)
return false;
if (
typeof resolution['requestMessageId'] !== 'string' ||
message['replyTo'] !== resolution['requestMessageId'] ||
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/channels/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,7 @@ export interface ChannelStore {
id: string,
message: ChannelMessage,
origin?: ChannelMessageOrigin,
beforeCommit?: () => boolean,
): Promise<ChannelMessage | undefined>;
readMessages(id: string, options?: ChannelReadOptions): ChannelMessage[];
queryMessages(id: string, options?: ChannelMessageQueryOptions): ChannelMessageQueryPage;
Expand Down Expand Up @@ -922,7 +923,7 @@ export function createChannelStore(options: ChannelStoreOptions): ChannelStore {
write(renamed);
return renamed;
},
appendMessage(id, message, origin) {
appendMessage(id, message, origin, beforeCommit) {
return enqueue(id, () => {
const record = read(id);
if (record === undefined) return undefined;
Expand All @@ -935,6 +936,7 @@ export function createChannelStore(options: ChannelStoreOptions): ChannelStore {
}
assertAttachmentRefs(message.attachments ?? []);
assertGrantRequestReply(record, message, priorMessages);
if (beforeCommit && !beforeCommit()) return undefined;
const durableMessage = { ...message };
delete durableMessage.grantRequestResolved;
delete durableMessage.replyToPreview;
Expand Down
Loading
Loading