@pmate/chat-sdk 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import { type ChatPresence } from "./presence.js";
2
+ import { type ChatUpdate } from "./updates.js";
2
3
  import { ChatActivityPublisher, type ActivityTarget, type ChatActivity, type ChatActivityState, type ChatActivityUpdate } from "./activity.js";
3
4
  import type { ChatMention } from "../mentions/model";
4
5
  import { type BotLoginSession } from "./botAuth.js";
@@ -39,6 +40,8 @@ export declare class ChatClient {
39
40
  private readonly publishers;
40
41
  private readonly pendingAcks;
41
42
  private readonly messageHandlers;
43
+ private readonly updateHandlers;
44
+ watchUpdates(handler: (update: ChatUpdate) => void): () => void;
42
45
  constructor(options: ChatClientOptions);
43
46
  connect(): Promise<this>;
44
47
  private initialize;
@@ -1,4 +1,5 @@
1
1
  import { ChatPresenceClient } from "./presence.js";
2
+ import { parseChatUpdate } from "./updates.js";
2
3
  import { ChatActivityStore, ChatActivityPublisher, createActivityMessage, parseChatActivity } from "./activity.js";
3
4
  import { loginPmateBot } from "./botAuth.js";
4
5
  import { parsePmateBotKeyJson } from "./botKey.js";
@@ -25,6 +26,11 @@ export class ChatClient {
25
26
  publishers = new Set();
26
27
  pendingAcks = new Map();
27
28
  messageHandlers = new Set();
29
+ updateHandlers = new Set();
30
+ watchUpdates(handler) {
31
+ this.updateHandlers.add(handler);
32
+ return () => { this.updateHandlers.delete(handler); };
33
+ }
28
34
  constructor(options) {
29
35
  this.options = options;
30
36
  if (options.botKey) {
@@ -288,6 +294,12 @@ export class ChatClient {
288
294
  return ack;
289
295
  }
290
296
  handleIncoming(message) {
297
+ const update = parseChatUpdate(message, this.options.appId);
298
+ if (update) {
299
+ for (const handler of this.updateHandlers)
300
+ handler(update);
301
+ return;
302
+ }
291
303
  if (message.opcode === ChatMsgOp.ACK) {
292
304
  const ack = message.body;
293
305
  const pending = this.pendingAcks.get(ack.ackOf);
@@ -0,0 +1,66 @@
1
+ /** Explicit content protocol, transported inside the existing text envelope. */
2
+ export type MarkdownBlock = {
3
+ id: string;
4
+ type: "markdown";
5
+ text: string;
6
+ };
7
+ export type ChartBlock = {
8
+ id: string;
9
+ type: "chart";
10
+ chart: {
11
+ type: "line" | "bar";
12
+ title: string;
13
+ x: string;
14
+ series: {
15
+ field: string;
16
+ label: string;
17
+ unit: string;
18
+ }[];
19
+ data: Record<string, string | number | null>[];
20
+ evidence: {
21
+ from: number;
22
+ to: number;
23
+ timezone: "UTC" | "Asia/Shanghai";
24
+ definition: string;
25
+ source: string;
26
+ queryId: string;
27
+ completeness: string;
28
+ };
29
+ };
30
+ };
31
+ export type MessageBlock = MarkdownBlock | ChartBlock;
32
+ export type RichMessageContent = {
33
+ format: "blocks";
34
+ version: 1;
35
+ revision: number;
36
+ blocks: MessageBlock[];
37
+ text: string;
38
+ };
39
+ export declare const CONTENT_LIMITS: {
40
+ readonly bytes: 65536;
41
+ readonly blocks: 20;
42
+ readonly text: 16000;
43
+ readonly points: 100;
44
+ readonly series: 4;
45
+ };
46
+ export declare function safeContentUrl(value: string): string | undefined;
47
+ export declare function validateMessageBlock(value: unknown): value is MessageBlock;
48
+ export type ContentResult = {
49
+ kind: "legacy";
50
+ } | {
51
+ kind: "invalid";
52
+ reason: string;
53
+ } | {
54
+ kind: "rich";
55
+ content: RichMessageContent;
56
+ };
57
+ export declare function readMessageContent(value: unknown): ContentResult;
58
+ /** Text projection is generated from blocks, never a second editable source. */
59
+ export declare function messageBlocksText(blocks: MessageBlock[]): string;
60
+ export declare function createRichMessageContent(blocks: MessageBlock[], revision?: number): RichMessageContent;
61
+ /** Ordered updates: incomplete charts remain invisible, replay never duplicates a block. */
62
+ export declare function applyContentUpdate(current: RichMessageContent, update: {
63
+ revision: number;
64
+ block: MessageBlock;
65
+ complete: boolean;
66
+ }): RichMessageContent;
@@ -0,0 +1,74 @@
1
+ export const CONTENT_LIMITS = { bytes: 65536, blocks: 20, text: 16000, points: 100, series: 4 };
2
+ const object = (value) => !!value && typeof value === "object" && !Array.isArray(value);
3
+ const label = (value, max = 200) => typeof value === "string" && value.trim().length > 0 && value.length <= max;
4
+ export function safeContentUrl(value) {
5
+ try {
6
+ const url = new URL(value);
7
+ return ["https:", "http:", "mailto:"].includes(url.protocol) ? url.href : undefined;
8
+ }
9
+ catch {
10
+ return undefined;
11
+ }
12
+ }
13
+ export function validateMessageBlock(value) {
14
+ if (!object(value) || !label(value.id, 100))
15
+ return false;
16
+ if (value.type === "markdown")
17
+ return typeof value.text === "string" && value.text.length <= CONTENT_LIMITS.text;
18
+ if (value.type !== "chart" || !object(value.chart))
19
+ return false;
20
+ const c = value.chart, e = c.evidence;
21
+ if (!["line", "bar"].includes(c.type) || !label(c.title) || !label(c.x, 100) || !Array.isArray(c.series) || !c.series.length || c.series.length > CONTENT_LIMITS.series || !Array.isArray(c.data) || c.data.length > CONTENT_LIMITS.points)
22
+ return false;
23
+ if (!c.series.every((s) => object(s) && label(s.field, 100) && label(s.label) && typeof s.unit === "string" && s.unit.length <= 50) || new Set(c.series.map((s) => s.field)).size !== c.series.length || c.series.some((s) => s.field === c.x))
24
+ return false;
25
+ if (!c.data.every((row) => object(row) && label(row[c.x]) && c.series.every((s) => row[s.field] === null || typeof row[s.field] === "number" && Number.isFinite(row[s.field]))))
26
+ return false;
27
+ return object(e) && Number.isSafeInteger(e.from) && Number.isSafeInteger(e.to) && e.from < e.to && ["UTC", "Asia/Shanghai"].includes(e.timezone) && label(e.definition, 2000) && label(e.completeness, 2000) && label(e.queryId) && label(e.source, 2048) && !!safeContentUrl(e.source);
28
+ }
29
+ export function readMessageContent(value) {
30
+ if (!object(value) || value.format === undefined)
31
+ return { kind: "legacy" };
32
+ if (value.format !== "blocks" || value.version !== 1)
33
+ return { kind: "invalid", reason: "Unsupported message content version" };
34
+ if (!Number.isSafeInteger(value.revision) || value.revision < 0 || !Array.isArray(value.blocks) || !value.blocks.length || value.blocks.length > CONTENT_LIMITS.blocks || typeof value.text !== "string" || value.text.length > CONTENT_LIMITS.text)
35
+ return { kind: "invalid", reason: "Invalid message content" };
36
+ try {
37
+ if (new TextEncoder().encode(JSON.stringify(value)).length > CONTENT_LIMITS.bytes)
38
+ return { kind: "invalid", reason: "Message content exceeds limit" };
39
+ }
40
+ catch {
41
+ return { kind: "invalid", reason: "Invalid message content" };
42
+ }
43
+ if (!value.blocks.every(validateMessageBlock) || new Set(value.blocks.map((b) => b.id)).size !== value.blocks.length)
44
+ return { kind: "invalid", reason: "Invalid or duplicate message block" };
45
+ return { kind: "rich", content: value };
46
+ }
47
+ /** Text projection is generated from blocks, never a second editable source. */
48
+ export function messageBlocksText(blocks) {
49
+ return blocks.map(block => block.type === "markdown" ? block.text : [block.chart.title, [block.chart.x, ...block.chart.series.map(s => `${s.label}${s.unit ? ` (${s.unit})` : ""}`)].join("\t"), ...block.chart.data.map(row => [row[block.chart.x], ...block.chart.series.map(s => row[s.field] ?? "缺失 / Missing")].join("\t")), `${new Date(block.chart.evidence.from).toISOString()} – ${new Date(block.chart.evidence.to).toISOString()} (${block.chart.evidence.timezone})`, block.chart.evidence.definition, block.chart.evidence.completeness, block.chart.evidence.source].join("\n")).join("\n\n");
50
+ }
51
+ export function createRichMessageContent(blocks, revision = 0) {
52
+ const content = { format: "blocks", version: 1, revision, blocks, text: messageBlocksText(blocks) };
53
+ const result = readMessageContent(content);
54
+ if (result.kind !== "rich")
55
+ throw new Error(result.kind === "invalid" ? result.reason : "Invalid message content");
56
+ return content;
57
+ }
58
+ /** Ordered updates: incomplete charts remain invisible, replay never duplicates a block. */
59
+ export function applyContentUpdate(current, update) {
60
+ if (update.revision <= current.revision)
61
+ return current;
62
+ if (update.revision !== current.revision + 1)
63
+ throw new Error("Content revision gap; fetch message snapshot");
64
+ if (!update.complete && update.block.type === "chart")
65
+ return { ...current, revision: update.revision };
66
+ if (!validateMessageBlock(update.block))
67
+ throw new Error("Invalid content update");
68
+ const blocks = current.blocks.slice(), index = blocks.findIndex(b => b.id === update.block.id);
69
+ if (index < 0)
70
+ blocks.push(update.block);
71
+ else
72
+ blocks[index] = update.block;
73
+ return createRichMessageContent(blocks, update.revision);
74
+ }
@@ -2,8 +2,11 @@ export * from "./botAuth.js";
2
2
  export * from "./botKey.js";
3
3
  export * from "./chatClient.js";
4
4
  export * from "./protocol.js";
5
+ export * from "./content.js";
5
6
  export * from "./readerClient.js";
6
7
  export * from "./thread.js";
8
+ export * from "./updater.js";
9
+ export * from "./updates.js";
7
10
  export * from "./activity.js";
8
11
  export * from "./call.js";
9
12
  export * from "./presence.js";
@@ -2,8 +2,11 @@ export * from "./botAuth.js";
2
2
  export * from "./botKey.js";
3
3
  export * from "./chatClient.js";
4
4
  export * from "./protocol.js";
5
+ export * from "./content.js";
5
6
  export * from "./readerClient.js";
6
7
  export * from "./thread.js";
8
+ export * from "./updater.js";
9
+ export * from "./updates.js";
7
10
  export * from "./activity.js";
8
11
  export * from "./call.js";
9
12
  export * from "./presence.js";
@@ -0,0 +1,11 @@
1
+ /** 按资源键合并刷新任务;聊天正文不经过此调度器。 */
2
+ export declare class RefreshUpdater {
3
+ private readonly windowMs;
4
+ private readonly onError;
5
+ private tasks;
6
+ private closed;
7
+ constructor(windowMs?: number, onError?: (error: unknown, key: string) => void);
8
+ invalidate(key: string, refresh: () => Promise<void>): void;
9
+ close(): void;
10
+ private run;
11
+ }
@@ -0,0 +1,55 @@
1
+ /** 按资源键合并刷新任务;聊天正文不经过此调度器。 */
2
+ export class RefreshUpdater {
3
+ windowMs;
4
+ onError;
5
+ tasks = new Map();
6
+ closed = false;
7
+ constructor(windowMs = 3_000, onError = () => { }) {
8
+ this.windowMs = windowMs;
9
+ this.onError = onError;
10
+ }
11
+ invalidate(key, refresh) {
12
+ if (this.closed)
13
+ return;
14
+ let task = this.tasks.get(key);
15
+ if (!task) {
16
+ task = { running: false, due: false, refresh };
17
+ this.tasks.set(key, task);
18
+ }
19
+ task.refresh = refresh;
20
+ if (task.timer || task.due)
21
+ return;
22
+ task.timer = setTimeout(() => {
23
+ task.timer = undefined;
24
+ task.due = true;
25
+ void this.run(key, task);
26
+ }, this.windowMs);
27
+ }
28
+ close() {
29
+ this.closed = true;
30
+ for (const task of this.tasks.values())
31
+ clearTimeout(task.timer);
32
+ this.tasks.clear();
33
+ }
34
+ async run(key, task) {
35
+ if (this.closed || task.running || !task.due)
36
+ return;
37
+ task.running = true;
38
+ task.due = false;
39
+ try {
40
+ await task.refresh();
41
+ }
42
+ catch (error) {
43
+ this.onError(error, key);
44
+ if (!this.closed)
45
+ this.invalidate(key, task.refresh);
46
+ }
47
+ finally {
48
+ task.running = false;
49
+ if (!this.closed && task.due)
50
+ void this.run(key, task);
51
+ else if (!task.timer)
52
+ this.tasks.delete(key);
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,14 @@
1
+ export type ChatUpdateType = "org.members.changed" | "org.profiles.changed";
2
+ export type ChatUpdate = {
3
+ eventId: string;
4
+ appId: string;
5
+ orgId: string;
6
+ type: ChatUpdateType;
7
+ revision: string;
8
+ };
9
+ /** Accept only silent updater deliveries from the trusted Hub notification sender. */
10
+ export declare function parseChatUpdate(message: {
11
+ opcode: number;
12
+ from: string;
13
+ body: unknown;
14
+ }, appId: string): ChatUpdate | null;
@@ -0,0 +1,12 @@
1
+ /** Accept only silent updater deliveries from the trusted Hub notification sender. */
2
+ export function parseChatUpdate(message, appId) {
3
+ if (message.opcode !== 5 || message.from !== "@hub/notifications")
4
+ return null;
5
+ const body = message.body;
6
+ const data = body?.data;
7
+ if (body?.kind !== "delivery" || body.displayPolicy !== "silent" || !data || data.appId !== appId
8
+ || !["org.members.changed", "org.profiles.changed"].includes(String(data.type))
9
+ || typeof body.notificationId !== "string" || typeof data.orgId !== "string" || typeof data.revision !== "string")
10
+ return null;
11
+ return { eventId: body.notificationId, appId, orgId: data.orgId, type: data.type, revision: data.revision };
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Cross-platform PMate chat input components, voice input primitives, and ASR helpers",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",