@pmate/chat-sdk 0.12.0-next.69 → 0.12.0-next.70

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.
package/README.md CHANGED
@@ -198,7 +198,7 @@ supported as an alternative; select one source. An ambiguous WebSocket AUTH_FAIL
198
198
  requires authentication rather than guessing that a permission failure expired.
199
199
  Closing fences late providers and prevents further authenticated network work.
200
200
 
201
- Sending accepts text/mentions/quotes, images, voice, recalls, reactions and
201
+ Sending accepts text/mentions/quotes, images, files, voice, recalls, reactions and
202
202
  invitations through the same typed content union. The SDK resolves the target
203
203
  and group roster, expands @all and assigns reaction revisions. The default
204
204
  Promise resolves after durable acceptance and returns `{id,timestamp}`; outgoing
@@ -206,6 +206,7 @@ status is part of the same message list. `{waitFor:"delivered"}` waits for the
206
206
  server acknowledgement. `retryMessage(id)` retries a failed exact wire.
207
207
 
208
208
  ```ts
209
+ await chat.sendMessage(threadId, {file:{url,name,size,mimeType}}) // uploaded HTTPS metadata
209
210
  await chat.sendMessage(threadId, {voice:{blob,durationMs}})
210
211
  await chat.sendMessage(threadId, {reaction:{messageId,emojiId,active:true}})
211
212
  await chat.sendMessage(threadId, {recall:{messageId}}, {waitFor:"delivered"})
@@ -1,3 +1,4 @@
1
+ import type { ChatFile } from "./fileMetadata.js";
1
2
  import type { ChatError } from "./chatError.js";
2
3
  import type { ChatMention } from "./mentions/model.js";
3
4
  import type { TokenProvider } from "./credentials.js";
@@ -73,6 +74,9 @@ type ContentChoices = {
73
74
  content: string;
74
75
  };
75
76
  };
77
+ file: {
78
+ file: ChatFile;
79
+ };
76
80
  image: {
77
81
  image: {
78
82
  url: string;
@@ -1,3 +1,5 @@
1
+ import type { ChatFile } from "./fileMetadata.js";
2
+ export type { ChatFile } from "./fileMetadata.js";
1
3
  import type { ChatError } from "./chatError.js";
2
4
  import type { ChatMention } from "./mentions/model.js";
3
5
  /** Stable product model. No storage, transport, Session or projection types. */
@@ -26,6 +28,7 @@ export type ChatMessage = Readonly<{
26
28
  senderName?: string;
27
29
  senderAvatar?: string;
28
30
  timeLabel?: string;
31
+ file?: ChatFile;
29
32
  image?: Readonly<{
30
33
  url: string;
31
34
  name?: string;
@@ -116,4 +119,3 @@ export type ChatSnapshot = Readonly<{
116
119
  export type ReadOptions = {
117
120
  signal?: AbortSignal;
118
121
  };
119
- export {};
@@ -83,10 +83,11 @@ export class ChatCredentials {
83
83
  }
84
84
  getToken = async () => { return (await this.acquire()).token; };
85
85
  fetch = async (input, init) => {
86
+ const transport = this.transport;
86
87
  const url = new URL(input instanceof Request ? input.url : String(input)), headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
87
88
  // Never attach an app credential to uploads, other origins or unauthenticated requests.
88
89
  if (url.origin !== this.origin || !headers.has("authorization"))
89
- return this.transport(input, init);
90
+ return transport(input, init);
90
91
  const callerSignal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
91
92
  const signal = AbortSignal.any([this.lifetime.signal, ...(callerSignal ? [callerSignal] : [])]);
92
93
  signal?.throwIfAborted();
@@ -94,7 +95,7 @@ export class ChatCredentials {
94
95
  const send = async (token) => {
95
96
  signal?.throwIfAborted();
96
97
  headers.set("authorization", `Bearer ${token}`);
97
- return this.transport(original instanceof Request ? original.clone() : original, { ...init, headers, signal });
98
+ return transport(original instanceof Request ? original.clone() : original, { ...init, headers, signal });
98
99
  };
99
100
  const used = await waitForCaller(this.acquire(), signal ?? undefined);
100
101
  const response = await send(used.token);
@@ -0,0 +1,9 @@
1
+ /** Durable file reference. Upload limits and download policy belong to the host. */
2
+ export type ChatFile = Readonly<{
3
+ url: string;
4
+ name: string;
5
+ size: number;
6
+ mimeType?: string;
7
+ expiresAt?: number;
8
+ }>;
9
+ export declare function parseChatFile(value: unknown): ChatFile | undefined;
@@ -0,0 +1,18 @@
1
+ export function parseChatFile(value) {
2
+ if (!value || typeof value !== "object")
3
+ return;
4
+ const file = value;
5
+ if (typeof file.name !== "string" || !file.name.trim() || file.name.length > 255 || /[\u0000-\u001f\\/]/u.test(file.name) || typeof file.url !== "string" || file.url.length > 8192 || !Number.isSafeInteger(file.size) || Number(file.size) < 0)
6
+ return;
7
+ try {
8
+ const url = new URL(file.url);
9
+ if (url.protocol !== "https:" || url.username || url.password)
10
+ return;
11
+ }
12
+ catch {
13
+ return;
14
+ }
15
+ return { url: file.url, name: file.name, size: file.size,
16
+ ...(typeof file.mimeType === "string" && file.mimeType.length <= 255 && !/[\r\n]/.test(file.mimeType) ? { mimeType: file.mimeType } : {}),
17
+ ...(typeof file.expiresAt === "number" && Number.isSafeInteger(file.expiresAt) && file.expiresAt > 0 ? { expiresAt: file.expiresAt } : {}), };
18
+ }
@@ -1,3 +1,4 @@
1
+ import { parseChatFile } from "./fileMetadata.js";
1
2
  const object = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
2
3
  const text = (value) => typeof value === "string" ? value : "";
3
4
  function media(value) {
@@ -30,6 +31,7 @@ function content(message) {
30
31
  const legacy = object(message.legacyDisplay);
31
32
  const validLegacy = legacy.id === message.id && legacy.threadId === message.threadId && legacy.orgId === message.teamId && legacy.from === message.from && typeof legacy.content === "string";
32
33
  const hint = validLegacy ? legacy : {};
34
+ const file = parseChatFile(payload.file === undefined ? hint.file : payload.file);
33
35
  const contentKind = envelope.contentKind, voice = object(payload.voice);
34
36
  const voiceHint = hint.kind === "voice";
35
37
  const voiceUrl = media(voice.url) ?? (voiceHint ? media(hint.audioUrl) : undefined) ?? "";
@@ -51,6 +53,10 @@ function content(message) {
51
53
  result.kind = "system";
52
54
  result.system = { type: body.action === "groupCreate" ? "group-created" : "group-updated", title: text(object(body.payload).title) || undefined };
53
55
  }
56
+ else if (file) {
57
+ result.kind = "attachment";
58
+ result.file = file;
59
+ }
54
60
  else if (hasVoice) {
55
61
  result.kind = "voice";
56
62
  result.voice = { url: voiceUrl, durationMs, ...(text(voice.transcript) || text(hint.transcript) ? { transcript: text(voice.transcript) || text(hint.transcript) } : {}) };
@@ -1,3 +1,4 @@
1
+ import { type ChatFile } from "../fileMetadata.js";
1
2
  import type { ChatMention } from "../mentions/model.js";
2
3
  import type { MessageScope } from "./messageStore.js";
3
4
  import type { SendMessageInput } from "./durableSender.js";
@@ -19,6 +20,9 @@ type ContentChoices = {
19
20
  mentions?: ChatMention[];
20
21
  quote?: MessageQuote;
21
22
  };
23
+ file: {
24
+ file: ChatFile;
25
+ };
22
26
  image: {
23
27
  image: {
24
28
  url: string;
@@ -1,5 +1,6 @@
1
+ import { parseChatFile } from "../fileMetadata.js";
1
2
  import { dmThreadId, groupThreadId } from "./thread.js";
2
- const contentKeys = ["text", "image", "voice", "recall", "reaction", "invitation", "groupCreate", "groupUpdate"];
3
+ const contentKeys = ["text", "file", "image", "voice", "recall", "reaction", "invitation", "groupCreate", "groupUpdate"];
3
4
  export function validMessageIdentifier(value) { return typeof value === "string" && value.length > 0 && new TextEncoder().encode(value).byteLength <= 256 && !/[\u0000-\u001f]/u.test(value); }
4
5
  function members(value) {
5
6
  if (!Array.isArray(value) || value.length > 256 || !value.every(validMessageIdentifier))
@@ -80,6 +81,12 @@ export function outgoingContent(scope, thread, input) {
80
81
  // The durable sender assigns the revision in the acceptance transaction.
81
82
  return { to: thread.targetId, ...(recipients ? { recipients } : {}), body: { kind: "message", threadKind: thread.kind, contentKind: "text", content: { ...identity, text: "[表情回应]", reaction: { version: 1, messageId: reaction.messageId, emojiId: reaction.emojiId, active: reaction.active, revision: 1 } } } };
82
83
  }
84
+ if (input.file) {
85
+ const file = parseChatFile(input.file);
86
+ if (!file)
87
+ throw new Error("INVALID_MESSAGE_FILE");
88
+ return { to: thread.targetId, ...(recipients ? { recipients } : {}), body: { kind: "message", threadKind: thread.kind, contentKind: "text", content: { ...base, text: `[文件] ${file.name}\n${file.url}`, file } } };
89
+ }
83
90
  let content, contentKind = "text", voiceBlob;
84
91
  if ("text" in input) {
85
92
  if (typeof input.text !== "string")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.69",
3
+ "version": "0.12.0-next.70",
4
4
  "description": "Local-first chat SDK, message subscriptions, reading state, and shared chat UI primitives",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",