M2: 基座能力 —— memory / toolbox / channel / message
- memory 任务记忆四性质(只追加·信封盖戳·治理先行·归侧)、活动记忆四层与条目三件套、
任务日志两级投影(结构化零 LLM / 叙述性小结不入事实库)、度量八指标只读投影
- toolbox 注册两审(静态审 + 试运行审)、效应等级决定运行时待遇、契约演进只增不破坏、
白名单与纯工具私有作用域、ToolInvoker(两缝之二,live/record/replay)
- channel 通道实例配置、入站归一(解析 → 系统写 → 数据事件)、出站发送与关联记录登记
- message 通知 / 跨任务征答(就地作答 · 驳回重答只增不改)/ 死信 / 无主兜底
app:message 接上 event 的 MessageFallback;派发器按目标 ID 取 advisory lock 串行消费 +
消费幂等表;/api/ingest 模拟邮件通道(mock 只在外界)
已验证:无主 RFQ 邮件入站 → 归属解析 no-match → 事件落库 → 兜底落 OP 岗位的消息要素
门禁五道全绿;150 个单元测试 + 1 个 PG 集成测试
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
co-authored by
Claude Opus 5
parent
14d4fa046a
commit
f0444f1417
@@ -0,0 +1,35 @@
|
||||
# module: channel
|
||||
|
||||
职责: 通道实例:配置(归属键提取规则 / 载荷映射 / 默认认领岗位)、入站归一
|
||||
(解析 → 系统写 → 数据事件)、出站发送与出站关联记录登记
|
||||
不负责: 归属解析与三级消费判定(见 event,经 `EventGateway` 端口)、
|
||||
台账写入的实现(见 ledger,经 `LedgerSystemWriter` 端口)、消息兜底的呈现(见 message)
|
||||
|
||||
公开出口: index.ts
|
||||
- 类型:`ChannelService` `ChannelInstanceConfig` `ExtractRule` `LedgerMapping`
|
||||
`NormalizedWrite` `InboundOutcome` `OutboundMail`
|
||||
- 错误:`ChannelNotFoundError` `ChannelDisabledError`
|
||||
- 端口类型:`ChannelRepository` `EventGateway` `LedgerSystemWriter` `MailTransport`
|
||||
`AttachmentArchive` `Clock` `IdGenerator`
|
||||
- 用例工厂:`createChannelService(deps: ChannelServiceDeps): ChannelService`
|
||||
- 纯函数:`extractFields` `normalize`
|
||||
- adapter:`createChannelRepository` `createSimulatedMailTransport` `createFileAttachmentArchive`
|
||||
- Drizzle 表:`channelTable` `outboundMailTable`
|
||||
|
||||
层级: L2(编码规范 §4)
|
||||
依赖: @contracts;@shared/db;port:`EventGateway`(app 注入 event)、
|
||||
`LedgerSystemWriter`(app 注入 ledger)、`MailTransport`(模拟通道)
|
||||
被依赖: release(出站发送)、app(治理台 · 系统管理 / ingest 路由)
|
||||
|
||||
不变量:
|
||||
- **活动认数据,不认通道**:入站一律归一为域.对象.字段的写入,通道对活动的前提不可见
|
||||
- 入站归一走 **系统写**(登记世界已发生的事实,免门、即时抛数据事件),
|
||||
绝不走草稿写、也不绕过 `ledger` 出口(§12.5#9)
|
||||
- 归属解析**不在本模块**:通道只交事实,判定归 event —— 匹配次序与降级口径一处实现
|
||||
- **无主不写台账**:仅有主入站才归一为系统写;无主入站只入事件流并走消息兜底
|
||||
- 无主键的映射结果丢弃(无从定位),由调用方按提取失败处置
|
||||
- 出站即登记关联记录:线程 ID 与往来地址是归属解析①③级的对表来源
|
||||
- 邮件通道是**外界**:MVP 用模拟通道(出站落库不真发,入站经 ingest 注入);
|
||||
系统内部机制一律真跑,不得 mock
|
||||
|
||||
影响半径: index/contract/port 未变 → 对外零影响
|
||||
@@ -0,0 +1,81 @@
|
||||
/** 入站归一的载荷映射 —— domain 测试零 mock(OS 6.2) */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractFields, normalize } from '../internal/domain/payload-mapping.ts';
|
||||
import type { ExtractRule, LedgerMapping } from '../internal/domain/payload-mapping.ts';
|
||||
|
||||
const fields = {
|
||||
subject: '回复:报价 Q202601',
|
||||
body: 'PN: LM358DR,我方接受单价 1.85 元',
|
||||
from: 'buyer@acme.com',
|
||||
to: ['bd@we.com'],
|
||||
};
|
||||
|
||||
const rules: ExtractRule[] = [
|
||||
{ key: 'quoteNo', regex: 'Q\\d{6}', source: 'subject' },
|
||||
{ key: 'pn', regex: 'PN:\\s*([A-Z0-9]+)', source: 'body' },
|
||||
{ key: 'sender', regex: '.+', source: 'from' },
|
||||
];
|
||||
|
||||
describe('extractFields', () => {
|
||||
it('按来源分别提取,捕获组优先', () => {
|
||||
expect(extractFields(fields, rules)).toEqual({
|
||||
quoteNo: 'Q202601',
|
||||
pn: 'LM358DR',
|
||||
sender: 'buyer@acme.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('不命中即不产出该键', () => {
|
||||
expect(extractFields({ ...fields, subject: '你好' }, [rules[0]!])).toEqual({});
|
||||
});
|
||||
|
||||
it('both 同时看主题与正文', () => {
|
||||
expect(
|
||||
extractFields({ ...fields, subject: '你好' }, [{ key: 'pn', regex: 'LM\\d+DR', source: 'both' }]),
|
||||
).toEqual({ pn: 'LM358DR' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalize —— 提取字段 → 域.对象.字段', () => {
|
||||
const mapping: LedgerMapping[] = [
|
||||
{ from: 'quoteNo', domain: 'quote', object: 'reply', field: 'quote_no', primaryKey: true },
|
||||
{ from: 'pn', domain: 'quote', object: 'reply', field: 'pn', primaryKey: true },
|
||||
{ from: 'sender', domain: 'quote', object: 'reply', field: 'sender' },
|
||||
{ from: '', domain: 'quote', object: 'reply', field: 'channel', constant: 'mail' },
|
||||
];
|
||||
|
||||
it('同一对象的字段合并成一次写入,主键进 key', () => {
|
||||
const writes = normalize(extractFields(fields, rules), mapping);
|
||||
expect(writes).toEqual([
|
||||
{
|
||||
domain: 'quote',
|
||||
object: 'reply',
|
||||
key: { quote_no: 'Q202601', pn: 'LM358DR' },
|
||||
values: { sender: 'buyer@acme.com', channel: 'mail' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('常量映射不依赖提取结果', () => {
|
||||
const writes = normalize({}, [{ from: 'x', domain: 'd', object: 'o', field: 'k', primaryKey: true, constant: 'C' }]);
|
||||
expect(writes[0]?.key).toEqual({ k: 'C' });
|
||||
});
|
||||
|
||||
it('无主键的写入丢弃 —— 无从定位', () => {
|
||||
expect(normalize({ sender: 'a@b.com' }, [{ from: 'sender', domain: 'd', object: 'o', field: 'f' }])).toEqual([]);
|
||||
});
|
||||
|
||||
it('缺提取值的映射跳过,不写 undefined', () => {
|
||||
const writes = normalize({ quoteNo: 'Q1' }, mapping);
|
||||
expect(writes[0]?.values).toEqual({ channel: 'mail' });
|
||||
expect(writes[0]?.key).toEqual({ quote_no: 'Q1' });
|
||||
});
|
||||
|
||||
it('多对象各成一次写入', () => {
|
||||
const writes = normalize({ a: '1', b: '2' }, [
|
||||
{ from: 'a', domain: 'd1', object: 'o1', field: 'k', primaryKey: true },
|
||||
{ from: 'b', domain: 'd2', object: 'o2', field: 'k', primaryKey: true },
|
||||
]);
|
||||
expect(writes).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* module: channel —— 对外类型(编码规范 §10.1)。
|
||||
*/
|
||||
import type { InboundMail, OwnershipResolution, TaskId } from '@contracts';
|
||||
import type {
|
||||
ChannelInstanceConfig,
|
||||
ExtractRule,
|
||||
LedgerMapping,
|
||||
NormalizedWrite,
|
||||
} from './internal/domain/payload-mapping.ts';
|
||||
|
||||
export type { ChannelInstanceConfig, ExtractRule, InboundMail, LedgerMapping, NormalizedWrite, OwnershipResolution };
|
||||
|
||||
export class ChannelNotFoundError extends Error {
|
||||
constructor(readonly channelId: string) {
|
||||
super(`通道实例不存在:${channelId}`);
|
||||
this.name = 'ChannelNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ChannelDisabledError extends Error {
|
||||
constructor(readonly channelId: string) {
|
||||
super(`通道实例已停用:${channelId}`);
|
||||
this.name = 'ChannelDisabledError';
|
||||
}
|
||||
}
|
||||
|
||||
export type InboundOutcome = {
|
||||
readonly eventId: string;
|
||||
readonly resolution: OwnershipResolution;
|
||||
readonly writes: readonly NormalizedWrite[];
|
||||
readonly listenersHit: number;
|
||||
readonly triggersHit: number;
|
||||
readonly fellBackToMessage: boolean;
|
||||
};
|
||||
|
||||
export type OutboundMail = {
|
||||
readonly taskId: TaskId;
|
||||
readonly to: readonly string[];
|
||||
readonly cc?: readonly string[];
|
||||
readonly subject: string;
|
||||
readonly body: string;
|
||||
readonly threadId?: string;
|
||||
readonly attachments?: ReadonlyArray<{ name: string; ref: string }>;
|
||||
};
|
||||
|
||||
export type ChannelService = {
|
||||
list(): Promise<ChannelInstanceConfig[]>;
|
||||
upsert(config: ChannelInstanceConfig): Promise<ChannelInstanceConfig>;
|
||||
/**
|
||||
* 入站:归属解析 → 归一为**系统写** → 数据事件。
|
||||
* 「环外回音经归属解析归一为数据写入后,以数据事件抛出 —— 活动认数据,不认通道。」
|
||||
*/
|
||||
ingestMail(channelId: string, mail: InboundMail): Promise<InboundOutcome>;
|
||||
/** 出站:触达世界的动作;发送即登记出站关联记录(归属解析①③级的对表来源) */
|
||||
sendMail(channelId: string, mail: OutboundMail): Promise<{ messageId: string; threadId: string }>;
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* module: channel —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||
*/
|
||||
export type {
|
||||
ChannelInstanceConfig,
|
||||
ChannelService,
|
||||
ExtractRule,
|
||||
InboundMail,
|
||||
InboundOutcome,
|
||||
LedgerMapping,
|
||||
NormalizedWrite,
|
||||
OutboundMail,
|
||||
} from './contract.ts';
|
||||
export { ChannelDisabledError, ChannelNotFoundError } from './contract.ts';
|
||||
|
||||
export type {
|
||||
AttachmentArchive,
|
||||
ChannelRepository,
|
||||
Clock,
|
||||
EventGateway,
|
||||
IdGenerator,
|
||||
LedgerSystemWriter,
|
||||
MailTransport,
|
||||
} from './port.ts';
|
||||
|
||||
export type { ChannelServiceDeps } from './internal/usecase/channel-service.ts';
|
||||
export { createChannelService } from './internal/usecase/channel-service.ts';
|
||||
|
||||
export { extractFields, normalize } from './internal/domain/payload-mapping.ts';
|
||||
|
||||
export {
|
||||
createChannelRepository,
|
||||
createFileAttachmentArchive,
|
||||
createSimulatedMailTransport,
|
||||
} from './internal/adapter/channel-store.ts';
|
||||
export { channelTable, outboundMailTable } from './internal/adapter/channel-schema.ts';
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* channel 的 sys schema 表(编码规范 §7 / ADR-002)。
|
||||
* 表定义单独成文件:drizzle.config.ts 以 `*-schema.ts` glob 收集。
|
||||
*/
|
||||
import { boolean, integer, jsonb, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { sys } from '@shared/db.ts';
|
||||
|
||||
export const channelTable = sys.table('channel_instance', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
kind: text('kind').notNull(),
|
||||
address: text('address').notNull(),
|
||||
enabled: boolean('enabled').notNull().default(true),
|
||||
/** 归属键提取规则(声明式,OS 6.2) */
|
||||
extract: jsonb('extract').notNull().default([]),
|
||||
/** 载荷映射:提取字段 → 域.对象.字段,与边映射同一形式 */
|
||||
mapping: jsonb('mapping').notNull().default([]),
|
||||
/** 无主入站的默认认领岗位 */
|
||||
fallbackPosition: text('fallback_position'),
|
||||
addressWindowMs: integer('address_window_ms').notNull().default(604_800_000),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
/** 出站存档:模拟通道把「已发出的信」落库,供演示与回放查看 */
|
||||
export const outboundMailTable = sys.table('outbound_mail', {
|
||||
id: text('id').primaryKey(),
|
||||
threadId: text('thread_id').notNull(),
|
||||
fromAddr: text('from_addr').notNull(),
|
||||
toAddrs: jsonb('to_addrs').notNull(),
|
||||
ccAddrs: jsonb('cc_addrs').notNull().default([]),
|
||||
subject: text('subject').notNull(),
|
||||
body: text('body').notNull(),
|
||||
attachments: jsonb('attachments').notNull().default([]),
|
||||
sentAt: timestamp('sent_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* channel 存储与传输 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
|
||||
*/
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
import { positionIdSchema } from '@contracts';
|
||||
import type { AttachmentArchive, ChannelRepository, MailTransport } from '../../port.ts';
|
||||
import type { ChannelInstanceConfig, ExtractRule, LedgerMapping } from '../domain/payload-mapping.ts';
|
||||
import { channelTable, outboundMailTable } from './channel-schema.ts';
|
||||
|
||||
type Row = typeof channelTable.$inferSelect;
|
||||
|
||||
function toConfig(row: Row): ChannelInstanceConfig {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind as ChannelInstanceConfig['kind'],
|
||||
address: row.address,
|
||||
enabled: row.enabled,
|
||||
extract: row.extract as ExtractRule[],
|
||||
mapping: row.mapping as LedgerMapping[],
|
||||
fallbackPosition: row.fallbackPosition === null ? undefined : positionIdSchema.parse(row.fallbackPosition),
|
||||
addressWindowMs: row.addressWindowMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function createChannelRepository(db: Db): ChannelRepository {
|
||||
return {
|
||||
async list() {
|
||||
const rows = await db.select().from(channelTable).orderBy(channelTable.name);
|
||||
return rows.map(toConfig);
|
||||
},
|
||||
async get(channelId) {
|
||||
const rows = await db.select().from(channelTable).where(eq(channelTable.id, channelId)).limit(1);
|
||||
return rows[0] === undefined ? undefined : toConfig(rows[0]);
|
||||
},
|
||||
async upsert(config) {
|
||||
const values = {
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
kind: config.kind,
|
||||
address: config.address,
|
||||
enabled: config.enabled,
|
||||
extract: config.extract,
|
||||
mapping: config.mapping,
|
||||
fallbackPosition: config.fallbackPosition ?? null,
|
||||
addressWindowMs: config.addressWindowMs,
|
||||
};
|
||||
const [row] = await db
|
||||
.insert(channelTable)
|
||||
.values(values)
|
||||
.onConflictDoUpdate({ target: channelTable.id, set: values })
|
||||
.returning();
|
||||
if (row === undefined) throw new Error('通道实例写入未返回行');
|
||||
return toConfig(row);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟邮件通道 —— **mock 只允许出现在外界**(README MVP 完成判据):
|
||||
* 出站信落库不真发,入站经 /api/ingest 注入。系统内部机制一律真跑。
|
||||
*/
|
||||
export function createSimulatedMailTransport(db: Db, ids: { next(): string }): MailTransport {
|
||||
return {
|
||||
async send(input) {
|
||||
const messageId = ids.next();
|
||||
const threadId = input.threadId ?? messageId;
|
||||
await db.insert(outboundMailTable).values({
|
||||
id: messageId,
|
||||
threadId,
|
||||
fromAddr: input.from,
|
||||
toAddrs: [...input.to],
|
||||
ccAddrs: [...input.cc],
|
||||
subject: input.subject,
|
||||
body: input.body,
|
||||
attachments: [...input.attachments],
|
||||
});
|
||||
return { messageId, threadId };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 原件存档 —— 落本地目录,返回存档地址作为信封的原件指针 */
|
||||
export function createFileAttachmentArchive(root: string): AttachmentArchive {
|
||||
return {
|
||||
async put(name, content) {
|
||||
const path = join(root, name);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, content, 'utf8');
|
||||
return `file://${path}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 通道入站的载荷映射 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 6.2)。
|
||||
*
|
||||
* 「有主入站归一为**数据写入**:写入目标由通道实例配置的载荷映射给定
|
||||
* (提取字段 → 域.对象.字段,**与边映射同一形式**),原件存档为附件对象,以数据事件抛出。」
|
||||
*
|
||||
* 活动认数据,不认通道 —— 通道对活动的前提不可见。
|
||||
*/
|
||||
import type { PositionId } from '@contracts';
|
||||
|
||||
export type ExtractRule = {
|
||||
readonly key: string;
|
||||
readonly regex: string;
|
||||
readonly source: 'subject' | 'body' | 'both' | 'from' | 'to';
|
||||
};
|
||||
|
||||
/** 提取字段 → 域.对象.字段 */
|
||||
export type LedgerMapping = {
|
||||
readonly from: string;
|
||||
readonly domain: string;
|
||||
readonly object: string;
|
||||
readonly field: string;
|
||||
/** 该字段是否参与主键 */
|
||||
readonly primaryKey?: boolean;
|
||||
/** 常量:from 留空时用它 */
|
||||
readonly constant?: string;
|
||||
};
|
||||
|
||||
export type ChannelInstanceConfig = {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly kind: 'mail' | 'im';
|
||||
readonly address: string;
|
||||
readonly enabled: boolean;
|
||||
/** 归属键提取规则(声明式) */
|
||||
readonly extract: readonly ExtractRule[];
|
||||
/** 载荷映射 */
|
||||
readonly mapping: readonly LedgerMapping[];
|
||||
/** 无主入站的默认认领岗位 */
|
||||
readonly fallbackPosition?: PositionId | undefined;
|
||||
readonly addressWindowMs: number;
|
||||
};
|
||||
|
||||
export type InboundFields = {
|
||||
readonly subject: string;
|
||||
readonly body: string;
|
||||
readonly from: string;
|
||||
readonly to: readonly string[];
|
||||
};
|
||||
|
||||
export function extractFields(fields: InboundFields, rules: readonly ExtractRule[]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const rule of rules) {
|
||||
const haystack =
|
||||
rule.source === 'subject'
|
||||
? fields.subject
|
||||
: rule.source === 'body'
|
||||
? fields.body
|
||||
: rule.source === 'from'
|
||||
? fields.from
|
||||
: rule.source === 'to'
|
||||
? fields.to.join(',')
|
||||
: `${fields.subject}\n${fields.body}`;
|
||||
const match = new RegExp(rule.regex).exec(haystack);
|
||||
const captured = match?.[1] ?? match?.[0];
|
||||
if (captured !== undefined) out[rule.key] = captured;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type NormalizedWrite = {
|
||||
readonly domain: string;
|
||||
readonly object: string;
|
||||
readonly key: Record<string, string>;
|
||||
readonly values: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 归一:提取结果 + 映射 → 一组「域.对象」的写入。
|
||||
* 同一对象的多个字段合并成一次写入;主键字段进 key,其余进 values。
|
||||
*/
|
||||
export function normalize(
|
||||
extracted: Readonly<Record<string, string>>,
|
||||
mapping: readonly LedgerMapping[],
|
||||
): NormalizedWrite[] {
|
||||
const byObject = new Map<string, { domain: string; object: string; key: Record<string, string>; values: Record<string, string> }>();
|
||||
|
||||
for (const rule of mapping) {
|
||||
const value = rule.constant ?? extracted[rule.from];
|
||||
if (value === undefined) continue;
|
||||
const objectKey = `${rule.domain}.${rule.object}`;
|
||||
const bucket = byObject.get(objectKey) ?? { domain: rule.domain, object: rule.object, key: {}, values: {} };
|
||||
if (rule.primaryKey === true) bucket.key[rule.field] = value;
|
||||
else bucket.values[rule.field] = value;
|
||||
byObject.set(objectKey, bucket);
|
||||
}
|
||||
|
||||
// 无主键的写入无从定位,丢弃并由调用方按「提取失败」处置
|
||||
return [...byObject.values()].filter((w) => Object.keys(w.key).length > 0);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* channel 用例 —— 入站归一与出站发送(编码规范 §5.3;语义出处 OS 6.2 / 07)。
|
||||
*/
|
||||
import type { InboundMail, TaskId } from '@contracts';
|
||||
import type { ChannelService, InboundOutcome, OutboundMail } from '../../contract.ts';
|
||||
import { ChannelDisabledError, ChannelNotFoundError } from '../../contract.ts';
|
||||
import type {
|
||||
AttachmentArchive,
|
||||
ChannelRepository,
|
||||
Clock,
|
||||
EventGateway,
|
||||
IdGenerator,
|
||||
LedgerSystemWriter,
|
||||
MailTransport,
|
||||
} from '../../port.ts';
|
||||
import { extractFields, normalize } from '../domain/payload-mapping.ts';
|
||||
|
||||
export type ChannelServiceDeps = {
|
||||
readonly channels: ChannelRepository;
|
||||
readonly events: EventGateway;
|
||||
readonly ledger: LedgerSystemWriter;
|
||||
readonly mail: MailTransport;
|
||||
readonly archive: AttachmentArchive;
|
||||
readonly clock: Clock;
|
||||
readonly ids: IdGenerator;
|
||||
};
|
||||
|
||||
export function createChannelService(deps: ChannelServiceDeps): ChannelService {
|
||||
async function require(channelId: string) {
|
||||
const config = await deps.channels.get(channelId);
|
||||
if (config === undefined) throw new ChannelNotFoundError(channelId);
|
||||
if (!config.enabled) throw new ChannelDisabledError(channelId);
|
||||
return config;
|
||||
}
|
||||
|
||||
return {
|
||||
async list() {
|
||||
return deps.channels.list();
|
||||
},
|
||||
|
||||
async upsert(config) {
|
||||
return deps.channels.upsert(config);
|
||||
},
|
||||
|
||||
async ingestMail(channelId, mail: InboundMail): Promise<InboundOutcome> {
|
||||
const config = await require(channelId);
|
||||
const receivedAt = new Date(mail.receivedAt);
|
||||
|
||||
// 原件存档为附件对象 —— 信封带原件指针,不带内容本身
|
||||
const originRef = await deps.archive.put(
|
||||
`inbound/${mail.messageId}.eml`,
|
||||
JSON.stringify({ ...mail }, null, 2),
|
||||
);
|
||||
|
||||
const extracted = extractFields(
|
||||
{ subject: mail.subject, body: mail.body, from: mail.from, to: mail.to },
|
||||
config.extract,
|
||||
);
|
||||
|
||||
// 归属解析 + 三级消费判定归 event(L1);channel 只把通道事实交上去
|
||||
const result = await deps.events.ingestInbound({
|
||||
facts: {
|
||||
...(mail.threadId === undefined ? {} : { threadId: mail.threadId }),
|
||||
from: mail.from,
|
||||
to: mail.to,
|
||||
subject: mail.subject,
|
||||
body: mail.body,
|
||||
receivedAt,
|
||||
},
|
||||
rules: {
|
||||
patterns: config.extract
|
||||
.filter((r) => r.source === 'subject' || r.source === 'body' || r.source === 'both')
|
||||
.map((r) => ({ key: r.key, regex: r.regex, source: r.source as 'subject' | 'body' | 'both' })),
|
||||
addressWindowMs: config.addressWindowMs,
|
||||
fallbackPosition: config.fallbackPosition,
|
||||
},
|
||||
draft: {
|
||||
type: 'data',
|
||||
correlation: { cause: { kind: 'inbound', ref: mail.messageId }, keys: extracted },
|
||||
payload: { data: { ...extracted, subject: mail.subject, from: mail.from }, originRef },
|
||||
occurredAt: receivedAt.toISOString(),
|
||||
source: { kind: 'channel', detail: config.name },
|
||||
},
|
||||
});
|
||||
|
||||
// 有主入站归一为**系统写**:登记世界已发生的事实,免门、即时抛数据事件
|
||||
const writes = normalize(extracted, config.mapping);
|
||||
if (result.resolution.outcome === 'owned') {
|
||||
for (const write of writes) {
|
||||
await deps.ledger.systemWrite({
|
||||
address: { domain: write.domain, object: write.object },
|
||||
key: write.key,
|
||||
values: write.values,
|
||||
cause: { kind: 'inbound', ref: mail.messageId },
|
||||
taskId: result.resolution.taskId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eventId: result.eventId,
|
||||
resolution: result.resolution,
|
||||
writes,
|
||||
listenersHit: result.plan.taskTargets.length,
|
||||
triggersHit: result.plan.triggerTargets.length,
|
||||
fellBackToMessage: result.plan.messageFallback,
|
||||
};
|
||||
},
|
||||
|
||||
async sendMail(channelId, mail: OutboundMail) {
|
||||
const config = await require(channelId);
|
||||
const sent = await deps.mail.send({
|
||||
from: config.address,
|
||||
to: mail.to,
|
||||
cc: mail.cc ?? [],
|
||||
subject: mail.subject,
|
||||
body: mail.body,
|
||||
...(mail.threadId === undefined ? {} : { threadId: mail.threadId }),
|
||||
attachments: mail.attachments ?? [],
|
||||
});
|
||||
|
||||
// 出站即登记关联记录 —— 归属解析①线程与③地址+时间窗的对表来源
|
||||
const sentAt = deps.clock.now();
|
||||
for (const address of mail.to) {
|
||||
await deps.events.recordOutbound({
|
||||
taskId: mail.taskId as TaskId,
|
||||
threadId: sent.threadId,
|
||||
address,
|
||||
sentAt,
|
||||
});
|
||||
}
|
||||
return sent;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@modules/channel",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./index.ts" }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* module: channel —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||
*/
|
||||
import type { EnvelopeDraft, OwnershipResolution, PositionId, TaskId } from '@contracts';
|
||||
import type { ChannelInstanceConfig } from './internal/domain/payload-mapping.ts';
|
||||
|
||||
export type Clock = { now(): Date };
|
||||
export type IdGenerator = { next(): string };
|
||||
|
||||
export type ChannelRepository = {
|
||||
list(): Promise<ChannelInstanceConfig[]>;
|
||||
get(channelId: string): Promise<ChannelInstanceConfig | undefined>;
|
||||
upsert(config: ChannelInstanceConfig): Promise<ChannelInstanceConfig>;
|
||||
};
|
||||
|
||||
/** 事件入口 —— app 注入 `event`(L1)的出口 */
|
||||
export type EventGateway = {
|
||||
ingestInbound(input: {
|
||||
facts: {
|
||||
threadId?: string;
|
||||
from: string;
|
||||
to: readonly string[];
|
||||
subject: string;
|
||||
body: string;
|
||||
receivedAt: Date;
|
||||
};
|
||||
rules: {
|
||||
patterns: ReadonlyArray<{ key: string; regex: string; source: 'subject' | 'body' | 'both' }>;
|
||||
addressWindowMs: number;
|
||||
fallbackPosition?: PositionId | undefined;
|
||||
};
|
||||
draft: EnvelopeDraft;
|
||||
}): Promise<{
|
||||
eventId: string;
|
||||
resolution: OwnershipResolution;
|
||||
plan: { taskTargets: readonly string[]; triggerTargets: readonly string[]; messageFallback: boolean };
|
||||
}>;
|
||||
recordOutbound(ref: { taskId: TaskId; threadId?: string; address: string; sentAt: Date }): Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 台账系统写 —— app 注入 `ledger`(L1)的 `systemWrite`。
|
||||
* 入站归一是「登记世界已发生的事实」,走**系统写**:不过放行门,写正式版并即时抛数据事件(OS 09)。
|
||||
*/
|
||||
export type LedgerSystemWriter = {
|
||||
systemWrite(input: {
|
||||
address: { domain: string; object: string };
|
||||
key: Record<string, string | number>;
|
||||
values: Record<string, unknown>;
|
||||
cause: { kind: 'inbound'; ref: string };
|
||||
taskId?: TaskId;
|
||||
}): Promise<void>;
|
||||
};
|
||||
|
||||
/** 邮件收发 —— 外界。MVP 允许模拟通道 / 录制回放替身(README MVP 完成判据) */
|
||||
export type MailTransport = {
|
||||
send(input: {
|
||||
readonly from: string;
|
||||
readonly to: readonly string[];
|
||||
readonly cc: readonly string[];
|
||||
readonly subject: string;
|
||||
readonly body: string;
|
||||
readonly threadId?: string;
|
||||
readonly attachments: ReadonlyArray<{ name: string; ref: string }>;
|
||||
}): Promise<{ messageId: string; threadId: string }>;
|
||||
};
|
||||
|
||||
/** 原件存档 —— 附件对象的存放;返回存档地址(信封的原件指针) */
|
||||
export type AttachmentArchive = {
|
||||
put(name: string, content: string): Promise<string>;
|
||||
};
|
||||
Reference in New Issue
Block a user