feat: add stable proactive delivery

This commit is contained in:
xmanrui 2026-08-30 11:24:53 +08:00
parent 410cb822d7
commit 31792d7bc8
88 changed files with 6427 additions and 488 deletions

View file

@ -1049,6 +1049,39 @@ export function createDingtalkApi({
return true;
},
async sendRobotText({ clientId, clientSecret, target, text, signal }) {
if (typeof text !== 'string' || !text.trim()) throw new TypeError('text is required');
const content = text;
const normalizedTarget = normalizeFileTarget(target);
const token = await accessToken({ clientId, clientSecret, signal });
const body = {
robotCode: normalizedTarget.robotCode,
msgKey: 'sampleText',
msgParam: JSON.stringify({ content }),
...(normalizedTarget.type === 'group'
? { openConversationId: normalizedTarget.openConversationId }
: { userIds: [normalizedTarget.userId] }),
};
const pathname = normalizedTarget.type === 'group'
? 'v1.0/robot/groupMessages/send'
: 'v1.0/robot/oToMessages/batchSend';
const response = await requestJson(fetchImpl, endpoint(apiBase, pathname), {
body,
headers: { 'x-acs-dingtalk-access-token': token },
signal,
action: '主动文字消息发送',
});
const rejection = rejectedProviderResponse(response);
if (rejection) {
throw new DingtalkApiError(
'send-rejected',
'钉钉服务拒绝了主动文字消息。',
{ providerCode: rejection },
);
}
return response;
},
async sendFile(request) {
return sendArtifact(request, {
uploadType: 'file',

View file

@ -406,6 +406,20 @@ export class DingtalkController {
});
}
async sendProactiveText(botId, target, text, options = {}) {
const config = this.#configStore.get(botId);
if (!config) throw new Error('Unknown DingTalk bot');
return this.#withBotTransition(botId, async () => {
const runtime = this.#runtimes.get(botId);
if (!runtime?.status?.ready || typeof runtime.sendProactiveText !== 'function') {
const error = new Error(t('钉钉消息连接当前离线'));
error.code = 'bot-not-connected';
throw error;
}
return runtime.sendProactiveText(target, text, options);
});
}
/** Removes one bot, its secret, runtime, and local conversation state. */
async deleteBot(botId) {
const config = this.#configStore.get(botId);

View file

@ -388,6 +388,45 @@ export class DingtalkRuntime {
});
}
async sendProactiveText(target, text, { signal } = {}) {
const userId = typeof target?.route?.userId === 'string'
? target.route.userId.trim() : '';
const openConversationId = typeof target?.route?.openConversationId === 'string'
? target.route.openConversationId.trim() : '';
if ((target?.kind === 'user' && (!userId || openConversationId))
|| (target?.kind === 'group' && (!openConversationId || userId))
|| (target?.kind !== 'user' && target?.kind !== 'group')) {
const error = new TypeError('Invalid DingTalk proactive delivery target');
error.code = 'invalid-target';
throw error;
}
if (!this.#status.ready || !this.#abortController) {
const error = new Error('DingTalk runtime is not connected');
error.code = 'bot-not-connected';
throw error;
}
signal?.throwIfAborted();
try {
await this.#api.sendRobotText({
clientId: this.#config.clientId,
clientSecret: this.#clientSecret,
target: {
type: target.kind,
robotCode: this.#config.clientId,
...(target.kind === 'user' ? { userId } : { openConversationId }),
},
text,
signal: signal ?? this.#abortController.signal,
});
} catch (cause) {
if (cause?.code !== 'send-rejected') throw cause;
const error = new Error('DingTalk rejected the proactive delivery target', { cause });
error.code = 'target-rejected';
throw error;
}
return { sent: true };
}
#pendingSenders() {
return typeof this.#state.pendingSenders === 'function'
? this.#state.pendingSenders()

View file

@ -501,6 +501,22 @@ export class DiscordRuntime {
return this.#bridge.sendConnectionTest(text);
}
async sendProactiveText(target, text, options = {}) {
if (!this.#status.ready || !this.#bridge) {
const error = new Error('Discord bot is not connected');
error.code = 'bot-not-connected';
throw error;
}
const channelId = typeof target?.route?.channelId === 'string'
? target.route.channelId.trim() : '';
if (target?.kind !== 'channel' || !channelId) {
const error = new TypeError('Invalid Discord proactive delivery target');
error.code = 'invalid-target';
throw error;
}
return this.#bridge.sendProactiveText({ channelId }, text, options);
}
async start() {
if (this.#status.ready && this.#socket) return this.status;
if (this.#starting) return this.#starting;

View file

@ -563,6 +563,41 @@ export class FeishuRuntime {
});
}
async sendProactiveText(target, text, { signal } = {}) {
if (!this.#status.ready || !this.#client) {
const error = new Error('飞书机器人尚未连接');
error.code = 'bot-not-connected';
throw error;
}
const receiveIdType = target?.kind === 'user' ? 'open_id'
: target?.kind === 'group' ? 'chat_id' : null;
const receiveId = target?.kind === 'user'
? nonEmptyString(target?.route?.openId)
: target?.kind === 'group'
? nonEmptyString(target?.route?.chatId)
: null;
if (!receiveIdType || !receiveId) {
const error = new TypeError('Invalid Feishu proactive delivery target');
error.code = 'invalid-target';
throw error;
}
signal?.throwIfAborted();
const response = await this.#client.im.v1.message.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
msg_type: 'text',
content: JSON.stringify({ text }),
},
});
if (response?.code && response.code !== 0) {
const error = new Error(`Feishu proactive delivery failed: ${response.msg || response.code}`);
error.code = 'target-rejected';
throw error;
}
return { sent: true };
}
stop(options = {}) {
if (this.#stopping) return this.#stopping;

View file

@ -537,6 +537,21 @@ export class MultiBotDshFeishuController {
});
}
async sendProactiveText(botId, target, text, options = {}) {
this.#assertOpen();
return this.#withBotTransition(botId, async () => {
this.#requireBot(botId);
const runtime = this.#runtimes.get(botId);
if (!isConnected(connectionStatus(runtime))
|| typeof runtime.sendProactiveText !== 'function') {
const error = new Error('飞书机器人尚未连接');
error.code = 'bot-not-connected';
throw error;
}
return runtime.sendProactiveText(target, text, options);
});
}
async disconnectBot(botId) {
this.#assertOpen();
// An operational pause only: credentials/config remain durable, so the

View file

@ -268,6 +268,20 @@ export class QqController {
});
}
async sendProactiveText(botId, target, text, options = {}) {
const config = this.#configStore.get(botId);
if (!config) throw new Error('Unknown QQ bot');
return this.#withBotTransition(botId, async () => {
const runtime = this.#runtimes.get(botId);
if (!runtime?.status?.ready || typeof runtime.sendProactiveText !== 'function') {
const error = new Error(t('QQ机器人尚未连接'));
error.code = 'bot-not-connected';
throw error;
}
return runtime.sendProactiveText(target, text, options);
});
}
async deleteBot(botId) {
const config = this.#configStore.get(botId);
if (!config) throw new Error('Unknown QQ bot');

View file

@ -97,6 +97,29 @@ export class QqRuntime {
return { sent: true };
}
async sendProactiveText(target, text, { signal } = {}) {
const nativeId = target?.kind === 'user'
? (typeof target?.route?.userOpenId === 'string' ? target.route.userOpenId.trim() : '')
: target?.kind === 'group'
? (typeof target?.route?.groupOpenId === 'string' ? target.route.groupOpenId.trim() : '')
: '';
if (!nativeId) {
const error = new TypeError('Invalid QQ proactive delivery target');
error.code = 'invalid-target';
throw error;
}
if (!this.#status.ready || !this.#bot) {
const error = new Error('QQ bot is not connected');
error.code = 'bot-not-connected';
throw error;
}
signal?.throwIfAborted();
return this.#bot.sendText({
scope: target.kind === 'user' ? 'c2c' : 'group',
targetId: nativeId,
}, text);
}
async start() {
if (this.#status.ready && this.#bot) return this.status;
if (this.#starting) return this.#starting;

View file

@ -49,8 +49,90 @@ function botIdOf(value) {
return value;
}
function deliveryTargetError(code, message, options) {
const error = new Error(message, options);
error.code = code;
return error;
}
function targetIdOf(value) {
if (typeof value !== 'string'
|| !/^[A-Za-z0-9._:@-]{1,128}$/.test(value)) {
throw deliveryTargetError('invalid-target', 'Invalid target id');
}
return value;
}
function jsonRecord(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw deliveryTargetError('invalid-target', 'Invalid target route');
}
try {
const serialized = JSON.stringify(value);
if (serialized === undefined) throw new TypeError('route is not JSON');
const route = JSON.parse(serialized);
if (!route || typeof route !== 'object' || Array.isArray(route)) {
throw new TypeError('route is not an object');
}
return route;
} catch (cause) {
throw deliveryTargetError('invalid-target', 'Invalid target route', { cause });
}
}
function normalizeDeliveryTarget(value, { targetId } = {}) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw deliveryTargetError('invalid-target', 'Invalid delivery target');
}
const allowed = targetId === undefined
? new Set(['targetId', 'name', 'kind', 'route'])
: new Set(['name', 'kind', 'route']);
if (Object.keys(value).some((key) => !allowed.has(key))) {
throw deliveryTargetError('invalid-target', 'Invalid delivery target');
}
const id = targetIdOf(targetId ?? value.targetId);
if (typeof value.kind !== 'string' || !/^[a-z][a-z0-9-]{0,31}$/.test(value.kind)) {
throw deliveryTargetError('invalid-target', 'Invalid target kind');
}
let name;
if (value.name !== undefined) {
if (typeof value.name !== 'string' || !value.name.trim() || value.name.trim().length > 80) {
throw deliveryTargetError('invalid-target', 'Invalid target name');
}
name = value.name.trim();
}
return {
targetId: id,
...(name === undefined ? {} : { name }),
kind: value.kind,
route: jsonRecord(value.route),
};
}
function normalizeDeliveryTargets(value) {
const deliveryTargets = Object.create(null);
if (value === undefined) return deliveryTargets;
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
try {
for (const [botId, targets] of Object.entries(value)) {
botIdOf(botId);
if (!targets || typeof targets !== 'object' || Array.isArray(targets)) return null;
const normalizedTargets = Object.create(null);
for (const [targetId, target] of Object.entries(targets)) {
const normalized = normalizeDeliveryTarget(target, { targetId });
const { targetId: _targetId, ...stored } = normalized;
normalizedTargets[targetId] = stored;
}
deliveryTargets[botId] = normalizedTargets;
}
} catch {
return null;
}
return deliveryTargets;
}
function normalizeDocument(value) {
if (!value || value.version !== 1 || !value.workspaces
if (!value || ![1, 2].includes(value.version) || !value.workspaces
|| typeof value.workspaces !== 'object' || Array.isArray(value.workspaces)) return null;
const workspaces = {};
for (const [botId, workspace] of Object.entries(value.workspaces)) {
@ -83,7 +165,16 @@ function normalizeDocument(value) {
}
}
}
return { version: 1, workspaces, agentPresets, contextEnhancement };
if (value.version === 1 && value.deliveryTargets !== undefined) return null;
const deliveryTargets = normalizeDeliveryTargets(value.deliveryTargets);
if (!deliveryTargets) return null;
return {
version: value.version,
workspaces,
agentPresets,
contextEnhancement,
deliveryTargets,
};
}
export async function validateWorkspacePath(value) {
@ -112,9 +203,11 @@ export async function validateWorkspacePath(value) {
export class BotWorkspaceStore {
#path;
#defaultWorkspace;
#version = 1;
#workspaces = {};
#agentPresets = {};
#contextEnhancement = {};
#deliveryTargets = Object.create(null);
#generations = new Map();
#nextGeneration = 1;
#incarnations = new Map();
@ -135,14 +228,18 @@ export class BotWorkspaceStore {
try {
const normalized = normalizeDocument(JSON.parse(await readFile(this.#path, 'utf8')));
if (!normalized) throw new Error('dsh-im workspace config is invalid');
this.#version = normalized.version;
this.#workspaces = normalized.workspaces;
this.#agentPresets = normalized.agentPresets;
this.#contextEnhancement = normalized.contextEnhancement;
this.#deliveryTargets = normalized.deliveryTargets;
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
this.#version = 1;
this.#workspaces = {};
this.#agentPresets = {};
this.#contextEnhancement = {};
this.#deliveryTargets = Object.create(null);
}
this.#generations.clear();
this.#nextGeneration = 1;
@ -181,6 +278,83 @@ export class BotWorkspaceStore {
: DEFAULT_CONTEXT_ENHANCEMENT_CONFIG;
}
listDeliveryTargets(botId) {
const id = botIdOf(botId);
if (!this.has(id)) throw deliveryTargetError('unknown-bot', 'Unknown bot');
return Object.entries(this.#deliveryTargets[id] ?? {})
.map(([targetId, target]) => normalizeDeliveryTarget(target, { targetId }))
.sort((left, right) => left.targetId.localeCompare(right.targetId));
}
deliveryTargetFor(botId, targetId) {
const id = botIdOf(botId);
const targetKey = targetIdOf(targetId);
if (!this.has(id)) throw deliveryTargetError('unknown-bot', 'Unknown bot');
const target = this.#deliveryTargets[id]?.[targetKey];
return target ? normalizeDeliveryTarget(target, { targetId: targetKey }) : null;
}
async createDeliveryTarget(botId, value) {
const id = botIdOf(botId);
const target = normalizeDeliveryTarget(value);
return this.#enqueue(id, async () => {
if (!this.has(id)) throw deliveryTargetError('unknown-bot', 'Unknown bot');
if (Object.hasOwn(this.#deliveryTargets[id] ?? {}, target.targetId)) {
throw deliveryTargetError('target-conflict', 'Target already exists');
}
const { targetId, ...stored } = target;
const next = {
...this.#deliveryTargets,
[id]: { ...(this.#deliveryTargets[id] ?? {}), [targetId]: stored },
};
await this.#persist(this.#contextEnhancement, next, 2);
this.#deliveryTargets = next;
this.#version = 2;
return normalizeDeliveryTarget(stored, { targetId });
});
}
async updateDeliveryTarget(botId, targetId, value) {
const id = botIdOf(botId);
const targetKey = targetIdOf(targetId);
const replacement = normalizeDeliveryTarget(value, { targetId: targetKey });
return this.#enqueue(id, async () => {
if (!this.has(id)) throw deliveryTargetError('unknown-bot', 'Unknown bot');
if (!Object.hasOwn(this.#deliveryTargets[id] ?? {}, targetKey)) {
throw deliveryTargetError('unknown-target', 'Unknown target');
}
const { targetId: _targetId, ...stored } = replacement;
const next = {
...this.#deliveryTargets,
[id]: { ...this.#deliveryTargets[id], [targetKey]: stored },
};
await this.#persist(this.#contextEnhancement, next, 2);
this.#deliveryTargets = next;
this.#version = 2;
return normalizeDeliveryTarget(stored, { targetId: targetKey });
});
}
async deleteDeliveryTarget(botId, targetId) {
const id = botIdOf(botId);
const targetKey = targetIdOf(targetId);
return this.#enqueue(id, async () => {
if (!this.has(id)) throw deliveryTargetError('unknown-bot', 'Unknown bot');
if (!Object.hasOwn(this.#deliveryTargets[id] ?? {}, targetKey)) {
throw deliveryTargetError('unknown-target', 'Unknown target');
}
const botTargets = { ...this.#deliveryTargets[id] };
delete botTargets[targetKey];
const next = { ...this.#deliveryTargets };
if (Object.keys(botTargets).length > 0) next[id] = botTargets;
else delete next[id];
await this.#persist(this.#contextEnhancement, next, 2);
this.#deliveryTargets = next;
this.#version = 2;
return true;
});
}
generationFor(botId) {
return this.#generations.get(botIdOf(botId)) ?? null;
}
@ -471,6 +645,7 @@ export class BotWorkspaceStore {
...Object.keys(this.#workspaces),
...Object.keys(this.#agentPresets),
...Object.keys(this.#contextEnhancement),
...Object.keys(this.#deliveryTargets),
...this.#dirtyRemovals,
]);
for (const botId of candidates) {
@ -518,10 +693,13 @@ export class BotWorkspaceStore {
const hadWorkspace = Object.hasOwn(this.#workspaces, id);
const hadPreset = Object.hasOwn(this.#agentPresets, id);
const hadContextEnhancement = Object.hasOwn(this.#contextEnhancement, id);
const needsCleanup = hadWorkspace || hadPreset || hadContextEnhancement || this.#dirtyRemovals.has(id);
const hadDeliveryTargets = Object.hasOwn(this.#deliveryTargets, id);
const needsCleanup = hadWorkspace || hadPreset || hadContextEnhancement
|| hadDeliveryTargets || this.#dirtyRemovals.has(id);
delete this.#workspaces[id];
delete this.#agentPresets[id];
delete this.#contextEnhancement[id];
delete this.#deliveryTargets[id];
this.#generations.delete(id);
this.#incarnations.delete(id);
if (!needsCleanup) return {
@ -551,14 +729,21 @@ export class BotWorkspaceStore {
return queued;
}
async #persist(contextEnhancement = this.#contextEnhancement) {
const document = { version: 1, workspaces: this.#workspaces };
async #persist(
contextEnhancement = this.#contextEnhancement,
deliveryTargets = this.#deliveryTargets,
version = this.#version,
) {
const document = { version, workspaces: this.#workspaces };
if (Object.keys(this.#agentPresets).length > 0) {
document.agentPresets = this.#agentPresets;
}
if (Object.keys(contextEnhancement).length > 0) {
document.contextEnhancement = contextEnhancement;
}
if (version >= 2 && Object.keys(deliveryTargets).length > 0) {
document.deliveryTargets = deliveryTargets;
}
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
const temporary = `${this.#path}.tmp`;
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
@ -572,7 +757,8 @@ export class BotWorkspaceStore {
async #persistCurrentDocument() {
if (Object.keys(this.#workspaces).length > 0
|| Object.keys(this.#agentPresets).length > 0
|| Object.keys(this.#contextEnhancement).length > 0) {
|| Object.keys(this.#contextEnhancement).length > 0
|| Object.keys(this.#deliveryTargets).length > 0) {
await this.#persist();
return;
}

View file

@ -469,6 +469,11 @@ export class TextHarnessBridge {
});
}
sendProactiveText(target, text, { signal } = {}) {
signal?.throwIfAborted();
return this.#bot.sendText(target, text);
}
async #deliverArtifacts(target, replyTo, artifacts = [], baseReceipt) {
const delivery = await deliverOutboundArtifacts({
artifacts,

View file

@ -227,6 +227,22 @@ export class TokenBotController {
});
}
async sendProactiveText(botId, target, text, options = {}) {
const config = this.#configStore.get(botId);
if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`);
return this.#withBotTransition(botId, async () => {
const runtime = this.#runtimes.get(botId);
if (!runtime?.status?.ready || typeof runtime.sendProactiveText !== 'function') {
const error = new Error(t('{label}机器人尚未连接', {
label: this.#descriptor.label,
}));
error.code = 'bot-not-connected';
throw error;
}
return runtime.sendProactiveText(target, text, options);
});
}
async deleteBot(botId) {
const config = this.#configStore.get(botId);
if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`);

View file

@ -189,6 +189,20 @@ export class SlackController {
});
}
async sendProactiveText(botId, target, text, options = {}) {
const config = this.#configStore.get(botId);
if (!config) throw new Error('Unknown Slack bot');
return this.#withBotTransition(botId, async () => {
const runtime = this.#runtimes.get(botId);
if (!runtime?.status?.ready || typeof runtime.sendProactiveText !== 'function') {
const error = new Error(t('Slack机器人尚未连接'));
error.code = 'bot-not-connected';
throw error;
}
return runtime.sendProactiveText(target, text, options);
});
}
async deleteBot(botId) {
const config = this.#configStore.get(botId);
if (!config) throw new Error('Unknown Slack bot');

View file

@ -407,6 +407,30 @@ export class SlackRuntime {
return this.#bridge.sendConnectionTest(text);
}
async sendProactiveText(target, text, options = {}) {
if (!this.#status.ready || !this.#bridge) {
const error = new Error('Slack bot is not connected');
error.code = 'bot-not-connected';
throw error;
}
const channelId = typeof target?.route?.channelId === 'string'
? target.route.channelId.trim() : '';
const threadTs = typeof target?.route?.threadTs === 'string'
? target.route.threadTs.trim() : '';
if (!channelId
|| (target?.kind !== 'conversation' && target?.kind !== 'thread')
|| (target.kind === 'thread' && !threadTs)
|| (target.kind === 'conversation' && threadTs)) {
const error = new TypeError('Invalid Slack proactive delivery target');
error.code = 'invalid-target';
throw error;
}
return this.#bridge.sendProactiveText({
channelId,
...(threadTs ? { threadTs } : {}),
}, text, options);
}
async start() {
if (this.#status.ready && this.#socket) return this.status;
if (this.#starting) return this.#starting;

View file

@ -719,6 +719,30 @@ export class TelegramRuntime {
return this.#bridge.sendConnectionTest(text);
}
async sendProactiveText(target, text, options = {}) {
if (!this.#status.ready || !this.#bridge) {
const error = new Error('Telegram bot is not connected');
error.code = 'bot-not-connected';
throw error;
}
const chatIdText = typeof target?.route?.chatId === 'string'
? target.route.chatId.trim() : '';
const chatId = Number(chatIdText);
const messageThreadId = target?.route?.messageThreadId;
if (!/^-?\d+$/.test(chatIdText) || !Number.isSafeInteger(chatId)
|| (target?.kind !== 'chat' && target?.kind !== 'topic')
|| (target.kind === 'topic' && (!Number.isSafeInteger(messageThreadId) || messageThreadId <= 0))
|| (target.kind === 'chat' && messageThreadId !== undefined)) {
const error = new TypeError('Invalid Telegram proactive delivery target');
error.code = 'invalid-target';
throw error;
}
return this.#bridge.sendProactiveText({
chatId,
...(target.kind === 'topic' ? { messageThreadId } : {}),
}, text, options);
}
async start() {
if (this.#status.ready && this.#pollTask) return this.status;
if (this.#starting) return this.#starting;

View file

@ -72,6 +72,10 @@ export class WecomStateStore {
await this.#persist();
}
snapshot() {
return structuredClone(this.#state);
}
async remove() {
try {
await unlink(this.#path);

View file

@ -257,6 +257,20 @@ export class WecomController {
});
}
async sendProactiveText(botId, target, text, options = {}) {
const config = this.#configStore.get(botId);
if (!config) throw new Error('Unknown Enterprise WeChat bot');
return this.#withBotTransition(botId, async () => {
const runtime = this.#runtimes.get(botId);
if (!runtime?.status?.ready || typeof runtime.sendProactiveText !== 'function') {
const error = new Error(t('企业微信连接当前离线'));
error.code = 'bot-not-connected';
throw error;
}
return runtime.sendProactiveText(target, text, options);
});
}
async deleteBot(botId) {
const config = this.#configStore.get(botId);
if (!config) throw new Error('Unknown Enterprise WeChat bot');

View file

@ -220,6 +220,27 @@ export class WecomRuntime {
});
}
async sendProactiveText(target, text, { signal } = {}) {
const chatId = typeof target?.route?.chatId === 'string'
? target.route.chatId.trim() : '';
if ((target?.kind !== 'user' && target?.kind !== 'group') || !chatId) {
const error = new TypeError('Invalid Enterprise WeChat proactive delivery target');
error.code = 'invalid-target';
throw error;
}
if (!this.#status.ready || !this.#client) {
const error = new Error('Enterprise WeChat runtime is not connected');
error.code = 'bot-not-connected';
throw error;
}
signal?.throwIfAborted();
await this.#client.sendMessage(chatId, {
msgtype: 'markdown',
markdown: { content: text },
});
return { sent: true };
}
async #stopActive() {
const client = this.#client;
const bridge = this.#bridge;

View file

@ -299,6 +299,20 @@ export class WeixinController {
});
}
async sendProactiveText(botId, target, text, options = {}) {
const config = this.#configStore.get(botId);
if (!config) throw new Error('Unknown Weixin account');
return this.#withBotTransition(botId, async () => {
const runtime = this.#runtimes.get(botId);
if (!runtime?.status?.ready || typeof runtime.sendProactiveText !== 'function') {
const error = new Error(t('微信连接当前离线'));
error.code = 'bot-not-connected';
throw error;
}
return runtime.sendProactiveText(target, text, options);
});
}
async deleteBot(botId) {
const config = this.#configStore.get(botId);
if (!config) throw new Error('Unknown Weixin account');

View file

@ -329,4 +329,28 @@ export class WeixinRuntime {
});
return { sent: true };
}
async sendProactiveText(target, text, { signal } = {}) {
const toUserId = typeof target?.route?.toUserId === 'string'
? target.route.toUserId.trim() : '';
if (target?.kind !== 'user' || !toUserId) {
const error = new TypeError('Invalid Weixin proactive delivery target');
error.code = 'invalid-target';
throw error;
}
if (!this.#status.ready || !this.#abortController) {
const error = new Error('Weixin runtime is not connected');
error.code = 'bot-not-connected';
throw error;
}
signal?.throwIfAborted();
await this.#api.sendText({
baseUrl: this.#config.baseUrl,
token: this.#token,
toUserId,
text,
signal: signal ?? this.#abortController.signal,
});
return { sent: true };
}
}

View file

@ -224,6 +224,20 @@ export class WhatsappController {
});
}
async sendProactiveText(botId, target, text, options = {}) {
const config = this.#configStore.get(botId);
if (!config) throw new Error('Unknown WhatsApp bot');
return this.#withBotTransition(botId, async () => {
const runtime = this.#runtimes.get(botId);
if (!runtime?.status?.ready || typeof runtime.sendProactiveText !== 'function') {
const error = new Error(t('WhatsApp机器人尚未连接'));
error.code = 'bot-not-connected';
throw error;
}
return runtime.sendProactiveText(target, text, options);
});
}
async setAccessPolicy(botId, value) {
if (this.#closed) throw new Error('WhatsApp controller is closed');
const accessPolicy = normalizeWhatsappAccessPolicy(value);

View file

@ -719,6 +719,25 @@ export class WhatsappRuntime {
return { sent: true };
}
async sendProactiveText(target, text, { signal } = {}) {
const jid = typeof target?.route?.jid === 'string' ? target.route.jid.trim() : '';
const validUser = target?.kind === 'user'
&& /^[^@\s]+@(s\.whatsapp\.net|lid)$/.test(jid);
const validGroup = target?.kind === 'group' && /^[^@\s]+@g\.us$/.test(jid);
if (!validUser && !validGroup) {
const error = new TypeError('Invalid WhatsApp proactive delivery target');
error.code = 'invalid-target';
throw error;
}
if (!this.#status.ready || !this.#client) {
const error = new Error(t('WhatsApp机器人尚未连接'));
error.code = 'bot-not-connected';
throw error;
}
signal?.throwIfAborted();
return this.#client.sendText({ jid }, text);
}
async stop() {
const session = this.#session;
const client = this.#client;