@newbase-clawchat/openclaw-clawchat 2026.4.29 → 2026.4.30
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 +33 -10
- package/dist/index.js +27 -0
- package/dist/src/api-client.js +156 -0
- package/dist/src/api-types.js +17 -0
- package/dist/src/buffered-stream.js +177 -0
- package/dist/src/channel.js +191 -0
- package/dist/src/client.js +176 -0
- package/dist/src/commands.js +35 -0
- package/dist/src/config.js +214 -0
- package/dist/src/inbound.js +133 -0
- package/dist/src/login.runtime.js +130 -0
- package/dist/src/media-runtime.js +85 -0
- package/dist/src/message-mapper.js +82 -0
- package/dist/src/outbound.js +181 -0
- package/dist/src/protocol.js +38 -0
- package/dist/src/reply-dispatcher.js +440 -0
- package/dist/src/runtime.js +288 -0
- package/dist/src/streaming.js +65 -0
- package/dist/src/tools-schema.js +38 -0
- package/dist/src/tools.js +287 -0
- package/openclaw.plugin.json +12 -0
- package/package.json +25 -5
- package/skills/clawchat-activate/SKILL.md +17 -8
- package/src/buffered-stream.test.ts +10 -0
- package/src/buffered-stream.ts +6 -6
- package/src/channel.outbound.test.ts +3 -3
- package/src/channel.ts +11 -3
- package/src/client.test.ts +8 -1
- package/src/client.ts +11 -10
- package/src/commands.test.ts +6 -0
- package/src/commands.ts +5 -1
- package/src/config.test.ts +3 -0
- package/src/config.ts +7 -0
- package/src/inbound.test.ts +4 -1
- package/src/inbound.ts +11 -10
- package/src/login.runtime.test.ts +36 -0
- package/src/login.runtime.ts +54 -26
- package/src/manifest.test.ts +98 -22
- package/src/outbound.test.ts +6 -5
- package/src/outbound.ts +8 -7
- package/src/reply-dispatcher.test.ts +418 -3
- package/src/reply-dispatcher.ts +137 -12
- package/src/runtime.ts +1 -0
- package/src/streaming.test.ts +12 -9
- package/src/streaming.ts +6 -6
- package/src/tools.test.ts +81 -18
- package/src/tools.ts +63 -72
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export function inferMediaKindFromMime(mime) {
|
|
2
|
+
if (!mime)
|
|
3
|
+
return "file";
|
|
4
|
+
if (mime.startsWith("image/"))
|
|
5
|
+
return "image";
|
|
6
|
+
if (mime.startsWith("audio/"))
|
|
7
|
+
return "audio";
|
|
8
|
+
if (mime.startsWith("video/"))
|
|
9
|
+
return "video";
|
|
10
|
+
return "file";
|
|
11
|
+
}
|
|
12
|
+
const DEFAULT_MEDIA_MAX_BYTES = 20 * 1024 * 1024;
|
|
13
|
+
/**
|
|
14
|
+
* Fetch each remote URL via the shared media runtime, persist to a local
|
|
15
|
+
* cache, and return the list of local paths.
|
|
16
|
+
*
|
|
17
|
+
* Failed items are logged at info level and dropped; the remaining items
|
|
18
|
+
* still resolve so a single bad URL doesn't blow up the whole inbound turn.
|
|
19
|
+
*/
|
|
20
|
+
export async function fetchInboundMedia(items, ctx) {
|
|
21
|
+
if (items.length === 0)
|
|
22
|
+
return [];
|
|
23
|
+
const maxBytes = ctx.maxBytes ?? DEFAULT_MEDIA_MAX_BYTES;
|
|
24
|
+
const paths = [];
|
|
25
|
+
for (const item of items) {
|
|
26
|
+
try {
|
|
27
|
+
const fetched = await ctx.runtime.channel.media.fetchRemoteMedia({
|
|
28
|
+
url: item.url,
|
|
29
|
+
maxBytes,
|
|
30
|
+
});
|
|
31
|
+
const saved = await ctx.runtime.channel.media.saveMediaBuffer(fetched.buffer, fetched.contentType ?? item.mime, "openclaw-clawchat-inbound", maxBytes, item.name ?? fetched.fileName);
|
|
32
|
+
paths.push(saved.path);
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
ctx.log?.info?.(`openclaw-clawchat inbound media skipped: ${item.url} (${err instanceof Error ? err.message : String(err)})`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return paths;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Upload each URL (remote or local path) to /media/upload via the api
|
|
42
|
+
* client and return a fragment ready to splice into `body.fragments`.
|
|
43
|
+
*
|
|
44
|
+
* Uses the host runtime's `runtime.media.loadWebMedia`, so local-root
|
|
45
|
+
* enforcement and media-loading policy stay aligned with the current
|
|
46
|
+
* OpenClaw runtime instead of a directly imported helper.
|
|
47
|
+
*
|
|
48
|
+
* Single-upload failures log at error and are dropped; the remaining
|
|
49
|
+
* fragments still come back so a partially-failing batch still sends the
|
|
50
|
+
* working media.
|
|
51
|
+
*/
|
|
52
|
+
export async function uploadOutboundMedia(urls, ctx) {
|
|
53
|
+
if (urls.length === 0)
|
|
54
|
+
return [];
|
|
55
|
+
const maxBytes = ctx.maxBytes ?? DEFAULT_MEDIA_MAX_BYTES;
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const url of urls) {
|
|
58
|
+
try {
|
|
59
|
+
const loaded = await ctx.runtime.media.loadWebMedia(url, {
|
|
60
|
+
maxBytes,
|
|
61
|
+
...(ctx.mediaLocalRoots && ctx.mediaLocalRoots.length > 0
|
|
62
|
+
? { localRoots: ctx.mediaLocalRoots }
|
|
63
|
+
: {}),
|
|
64
|
+
});
|
|
65
|
+
const uploaded = await ctx.apiClient.uploadMedia({
|
|
66
|
+
buffer: loaded.buffer,
|
|
67
|
+
filename: loaded.fileName ?? "upload.bin",
|
|
68
|
+
mime: loaded.contentType,
|
|
69
|
+
});
|
|
70
|
+
const fragment = {
|
|
71
|
+
kind: inferMediaKindFromMime(uploaded.mime),
|
|
72
|
+
url: uploaded.url,
|
|
73
|
+
mime: uploaded.mime,
|
|
74
|
+
size: uploaded.size,
|
|
75
|
+
};
|
|
76
|
+
if (loaded.fileName)
|
|
77
|
+
fragment.name = loaded.fileName;
|
|
78
|
+
out.push(fragment);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
ctx.log?.error?.(`openclaw-clawchat outbound media upload failed: ${url} (${err instanceof Error ? err.message : String(err)})`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const MEDIA_KINDS = new Set(["image", "file", "audio", "video"]);
|
|
2
|
+
function isMediaKind(kind) {
|
|
3
|
+
return MEDIA_KINDS.has(kind);
|
|
4
|
+
}
|
|
5
|
+
function renderMediaPlaceholder(fragment) {
|
|
6
|
+
const kind = String(fragment.kind ?? "");
|
|
7
|
+
const url = typeof fragment.url === "string" ? fragment.url : "";
|
|
8
|
+
if (!url)
|
|
9
|
+
return "";
|
|
10
|
+
const name = typeof fragment.name === "string" && fragment.name.trim()
|
|
11
|
+
? fragment.name
|
|
12
|
+
: kind === "image"
|
|
13
|
+
? "image"
|
|
14
|
+
: kind === "audio"
|
|
15
|
+
? "audio"
|
|
16
|
+
: kind === "video"
|
|
17
|
+
? "video"
|
|
18
|
+
: "file";
|
|
19
|
+
return kind === "image" ? `` : `[${name}](${url})`;
|
|
20
|
+
}
|
|
21
|
+
export function fragmentsToText(fragments, opts = {}) {
|
|
22
|
+
const fallback = opts.mentionFallbackIds ?? [];
|
|
23
|
+
let fallbackCursor = 0;
|
|
24
|
+
const parts = fragments.map((fragment) => {
|
|
25
|
+
const f = fragment;
|
|
26
|
+
if (f.kind === "text" && typeof f.text === "string") {
|
|
27
|
+
return f.text;
|
|
28
|
+
}
|
|
29
|
+
if (f.kind === "mention") {
|
|
30
|
+
const display = typeof f.display === "string" ? f.display : undefined;
|
|
31
|
+
if (display && display.trim())
|
|
32
|
+
return display;
|
|
33
|
+
const id = typeof f.user_id === "string" ? f.user_id : undefined;
|
|
34
|
+
if (id && id.trim())
|
|
35
|
+
return `@${id}`;
|
|
36
|
+
const fallbackId = fallback[fallbackCursor++];
|
|
37
|
+
if (fallbackId)
|
|
38
|
+
return `@${fallbackId}`;
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
if (typeof f.kind === "string" && isMediaKind(f.kind)) {
|
|
42
|
+
return renderMediaPlaceholder(f);
|
|
43
|
+
}
|
|
44
|
+
return "";
|
|
45
|
+
});
|
|
46
|
+
return parts.join("").trim();
|
|
47
|
+
}
|
|
48
|
+
export function textToFragments(text) {
|
|
49
|
+
if (!text || !text.trim())
|
|
50
|
+
return [];
|
|
51
|
+
return [{ kind: "text", text }];
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Extract media fragments from a body (image/file/audio/video). Skips
|
|
55
|
+
* entries missing `url`. Preserves all optional metadata fields the
|
|
56
|
+
* SDK passes through (mime/size/width/height/duration/name).
|
|
57
|
+
*/
|
|
58
|
+
export function extractMediaFragments(fragments) {
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const fragment of fragments) {
|
|
61
|
+
const f = fragment;
|
|
62
|
+
if (typeof f.kind !== "string" || !isMediaKind(f.kind))
|
|
63
|
+
continue;
|
|
64
|
+
if (typeof f.url !== "string" || !f.url)
|
|
65
|
+
continue;
|
|
66
|
+
const item = { kind: f.kind, url: f.url };
|
|
67
|
+
if (typeof f.name === "string")
|
|
68
|
+
item.name = f.name;
|
|
69
|
+
if (typeof f.mime === "string")
|
|
70
|
+
item.mime = f.mime;
|
|
71
|
+
if (typeof f.size === "number")
|
|
72
|
+
item.size = f.size;
|
|
73
|
+
if (typeof f.width === "number")
|
|
74
|
+
item.width = f.width;
|
|
75
|
+
if (typeof f.height === "number")
|
|
76
|
+
item.height = f.height;
|
|
77
|
+
if (typeof f.duration === "number")
|
|
78
|
+
item.duration = f.duration;
|
|
79
|
+
out.push(item);
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { createAttachedChannelResultAdapter, } from "openclaw/plugin-sdk/channel-send-result";
|
|
2
|
+
import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
|
|
3
|
+
import { createOpenclawClawlingApiClient } from "./api-client.js";
|
|
4
|
+
import { CHANNEL_ID, resolveOpenclawClawlingAccount } from "./config.js";
|
|
5
|
+
import { textToFragments } from "./message-mapper.js";
|
|
6
|
+
import { uploadOutboundMedia } from "./media-runtime.js";
|
|
7
|
+
import { getOpenclawClawlingClient, getOpenclawClawlingRuntime, waitForOpenclawClawlingClient, } from "./runtime.js";
|
|
8
|
+
/**
|
|
9
|
+
* Parse an agent-initiated outbound recipient string into the new-protocol
|
|
10
|
+
* `chat_id` + `chat_type` pair.
|
|
11
|
+
*
|
|
12
|
+
* Accepted forms (case-insensitive prefix):
|
|
13
|
+
* - `cc:{chat_id}` → direct
|
|
14
|
+
* - `clawchat:{chat_id}` → direct
|
|
15
|
+
* - `openclaw-clawchat:{chat_id}` → direct
|
|
16
|
+
* - `cc:direct:{chat_id}` → direct
|
|
17
|
+
* - `cc:group:{chat_id}` → group
|
|
18
|
+
* - `clawchat:direct:{chat_id}` → direct
|
|
19
|
+
* - `clawchat:group:{chat_id}` → group
|
|
20
|
+
* - `openclaw-clawchat:direct:{chat_id}` → direct
|
|
21
|
+
* - `openclaw-clawchat:group:{chat_id}` → group
|
|
22
|
+
* - bare `{chat_id}` → direct (backward compat)
|
|
23
|
+
*/
|
|
24
|
+
export function parseOpenclawRecipient(to) {
|
|
25
|
+
const raw = (to ?? "").trim();
|
|
26
|
+
if (!raw)
|
|
27
|
+
throw new Error("openclaw-clawchat: outbound `to` is empty");
|
|
28
|
+
const firstColon = raw.indexOf(":");
|
|
29
|
+
if (firstColon < 0)
|
|
30
|
+
return { chatId: raw, chatType: "direct" };
|
|
31
|
+
const scheme = raw.slice(0, firstColon).toLowerCase();
|
|
32
|
+
const rest = raw.slice(firstColon + 1);
|
|
33
|
+
if (scheme !== "cc" && scheme !== "clawchat" && scheme !== CHANNEL_ID) {
|
|
34
|
+
return { chatId: raw, chatType: "direct" };
|
|
35
|
+
}
|
|
36
|
+
const secondColon = rest.indexOf(":");
|
|
37
|
+
if (secondColon >= 0) {
|
|
38
|
+
const typeToken = rest.slice(0, secondColon).toLowerCase();
|
|
39
|
+
const chatId = rest.slice(secondColon + 1).trim();
|
|
40
|
+
if ((typeToken === "direct" || typeToken === "group") && chatId) {
|
|
41
|
+
return { chatId, chatType: typeToken };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const chatId = rest.trim();
|
|
45
|
+
if (!chatId)
|
|
46
|
+
throw new Error(`openclaw-clawchat: missing chat_id in "${to}"`);
|
|
47
|
+
return { chatId, chatType: "direct" };
|
|
48
|
+
}
|
|
49
|
+
export async function sendOpenclawClawlingText(params) {
|
|
50
|
+
const text = (params.text ?? "").trim();
|
|
51
|
+
const richFragments = params.richFragments ?? [];
|
|
52
|
+
const mediaFragments = params.mediaFragments ?? [];
|
|
53
|
+
if (!text && richFragments.length === 0 && mediaFragments.length === 0) {
|
|
54
|
+
params.log?.info?.(`[${params.account.accountId}] openclaw-clawchat outbound suppressed: empty text and no media`);
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const mentions = params.mentions ?? [];
|
|
58
|
+
const textFragments = text ? textToFragments(text) : [];
|
|
59
|
+
// Cast at the SDK boundary: each MediaItem object is structurally compatible
|
|
60
|
+
// with one of the SDK's narrow Fragment members (ImageFragment / FileFragment /
|
|
61
|
+
// AudioFragment / VideoFragment) based on its runtime `kind`. The wide local
|
|
62
|
+
// shape lets us build a single uniform array without a per-kind switch.
|
|
63
|
+
const fragments = [...textFragments, ...richFragments, ...mediaFragments];
|
|
64
|
+
const useReply = params.replyCtx && mediaFragments.length === 0;
|
|
65
|
+
if (params.replyCtx && mediaFragments.length > 0) {
|
|
66
|
+
params.log?.info?.(`[${params.account.accountId}] openclaw-clawchat replyCtx + media: downgraded to sendMessage`);
|
|
67
|
+
}
|
|
68
|
+
let ack;
|
|
69
|
+
let mode;
|
|
70
|
+
if (useReply && params.replyCtx) {
|
|
71
|
+
mode = "reply";
|
|
72
|
+
ack = await params.client.replyMessage({
|
|
73
|
+
chat_id: params.to.chatId,
|
|
74
|
+
mode: "normal",
|
|
75
|
+
replyTo: {
|
|
76
|
+
msgId: params.replyCtx.replyToMessageId,
|
|
77
|
+
senderId: params.replyCtx.replyPreviewChatId ?? params.replyCtx.replyPreviewSenderId,
|
|
78
|
+
nickName: params.replyCtx.replyPreviewNickName,
|
|
79
|
+
fragments: [{ kind: "text", text: params.replyCtx.replyPreviewText }],
|
|
80
|
+
},
|
|
81
|
+
body: { fragments },
|
|
82
|
+
context: { mentions },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
mode = "send";
|
|
87
|
+
ack = await params.client.sendMessage({
|
|
88
|
+
chat_id: params.to.chatId,
|
|
89
|
+
mode: "normal",
|
|
90
|
+
body: { fragments },
|
|
91
|
+
context: { mentions, reply: null },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
params.log?.info?.(`[${params.account.accountId}] openclaw-clawchat outbound mode=${mode} msg=${ack.payload.message_id} text_len=${text.length} media=${mediaFragments.length} trace=${ack.trace_id}`);
|
|
95
|
+
return {
|
|
96
|
+
messageId: ack.payload.message_id,
|
|
97
|
+
acceptedAt: ack.payload.accepted_at,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Send one or more media fragments (image / file / audio / video) to the
|
|
102
|
+
* given target, with an optional text caption.
|
|
103
|
+
*
|
|
104
|
+
* Validates that mediaFragments is non-empty (returns null + info log
|
|
105
|
+
* otherwise) and delegates to {@link sendOpenclawClawlingText} for the
|
|
106
|
+
* actual envelope construction. Reuses the existing replyCtx-downgrade,
|
|
107
|
+
* ack backfill, and log shape.
|
|
108
|
+
*/
|
|
109
|
+
export async function sendOpenclawClawlingMedia(params) {
|
|
110
|
+
if (params.mediaFragments.length === 0) {
|
|
111
|
+
params.log?.info?.(`[${params.account.accountId}] openclaw-clawchat sendMedia called with empty mediaFragments; suppressed`);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return await sendOpenclawClawlingText({
|
|
115
|
+
client: params.client,
|
|
116
|
+
account: params.account,
|
|
117
|
+
to: params.to,
|
|
118
|
+
text: params.text ?? "",
|
|
119
|
+
mediaFragments: params.mediaFragments,
|
|
120
|
+
...(params.replyCtx ? { replyCtx: params.replyCtx } : {}),
|
|
121
|
+
...(params.mentions ? { mentions: params.mentions } : {}),
|
|
122
|
+
...(params.log ? { log: params.log } : {}),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
export const openclawClawlingOutbound = {
|
|
126
|
+
deliveryMode: "direct",
|
|
127
|
+
chunker: (text, limit) => chunkMarkdownText(text, limit),
|
|
128
|
+
chunkerMode: "markdown",
|
|
129
|
+
textChunkLimit: 4000,
|
|
130
|
+
...createAttachedChannelResultAdapter({
|
|
131
|
+
channel: CHANNEL_ID,
|
|
132
|
+
sendText: async ({ cfg, to, text }) => {
|
|
133
|
+
const account = resolveOpenclawClawlingAccount(cfg);
|
|
134
|
+
const client = getOpenclawClawlingClient(account.accountId) ??
|
|
135
|
+
(await waitForOpenclawClawlingClient(account.accountId));
|
|
136
|
+
const result = await sendOpenclawClawlingText({
|
|
137
|
+
client,
|
|
138
|
+
account,
|
|
139
|
+
to: parseOpenclawRecipient(to),
|
|
140
|
+
text,
|
|
141
|
+
});
|
|
142
|
+
return {
|
|
143
|
+
to,
|
|
144
|
+
messageId: result?.messageId ?? `${account.userId}-${Date.now()}`,
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots }) => {
|
|
148
|
+
const account = resolveOpenclawClawlingAccount(cfg);
|
|
149
|
+
const client = getOpenclawClawlingClient(account.accountId) ??
|
|
150
|
+
(await waitForOpenclawClawlingClient(account.accountId));
|
|
151
|
+
if (!mediaUrl?.trim()) {
|
|
152
|
+
throw new Error("openclaw-clawchat sendMedia requires mediaUrl");
|
|
153
|
+
}
|
|
154
|
+
const runtime = getOpenclawClawlingRuntime();
|
|
155
|
+
const apiClient = createOpenclawClawlingApiClient({
|
|
156
|
+
baseUrl: account.baseUrl,
|
|
157
|
+
token: account.token,
|
|
158
|
+
userId: account.userId,
|
|
159
|
+
});
|
|
160
|
+
const mediaFragments = await uploadOutboundMedia([mediaUrl.trim()], {
|
|
161
|
+
apiClient,
|
|
162
|
+
runtime,
|
|
163
|
+
...(mediaLocalRoots ? { mediaLocalRoots } : {}),
|
|
164
|
+
});
|
|
165
|
+
if (mediaFragments.length === 0) {
|
|
166
|
+
throw new Error(`openclaw-clawchat failed to upload media: ${mediaUrl}`);
|
|
167
|
+
}
|
|
168
|
+
const result = await sendOpenclawClawlingMedia({
|
|
169
|
+
client,
|
|
170
|
+
account,
|
|
171
|
+
to: parseOpenclawRecipient(to),
|
|
172
|
+
text,
|
|
173
|
+
mediaFragments,
|
|
174
|
+
});
|
|
175
|
+
return {
|
|
176
|
+
to,
|
|
177
|
+
messageId: result?.messageId ?? `${account.userId}-${Date.now()}`,
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
}),
|
|
181
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local narrow guards for inbound protocol envelopes.
|
|
3
|
+
*
|
|
4
|
+
* The SDK's `message` event hands us `Envelope<unknown>`. Before casting the
|
|
5
|
+
* payload to `DownlinkMessageSendPayload` we run these cheap structural checks
|
|
6
|
+
* so runtime errors surface as skipped messages, not crashes.
|
|
7
|
+
*/
|
|
8
|
+
export function isInboundMessagePayload(payload) {
|
|
9
|
+
if (!payload || typeof payload !== "object")
|
|
10
|
+
return false;
|
|
11
|
+
const p = payload;
|
|
12
|
+
if (typeof p.message_id !== "string" || !p.message_id)
|
|
13
|
+
return false;
|
|
14
|
+
if (typeof p.message !== "object" || p.message === null)
|
|
15
|
+
return false;
|
|
16
|
+
const m = p.message;
|
|
17
|
+
if (typeof m.body !== "object" || m.body === null)
|
|
18
|
+
return false;
|
|
19
|
+
const body = m.body;
|
|
20
|
+
if (!Array.isArray(body.fragments))
|
|
21
|
+
return false;
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
export function hasRenderableText(message) {
|
|
25
|
+
const fragments = message?.body?.fragments ?? [];
|
|
26
|
+
return fragments.some((f) => ((f.kind === "text" &&
|
|
27
|
+
typeof f.text === "string" &&
|
|
28
|
+
f.text.trim().length > 0) ||
|
|
29
|
+
(typeof f.kind === "string" &&
|
|
30
|
+
["image", "file", "audio", "video"].includes(f.kind) &&
|
|
31
|
+
typeof f.url === "string" &&
|
|
32
|
+
f.url.trim().length > 0)));
|
|
33
|
+
}
|
|
34
|
+
export function isGroupSender(sender) {
|
|
35
|
+
if (!sender || typeof sender !== "object")
|
|
36
|
+
return false;
|
|
37
|
+
return sender.type === "group";
|
|
38
|
+
}
|