@ascegu/teamily 1.0.23 → 1.0.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ascegu/teamily",
3
- "version": "1.0.23",
3
+ "version": "1.0.25",
4
4
  "description": "OpenClaw Teamily channel plugin - Team instant messaging server integration",
5
5
  "keywords": [
6
6
  "channel",
package/src/channel.ts CHANGED
@@ -30,7 +30,12 @@ import {
30
30
  } from "./accounts.js";
31
31
  import { TeamilyConfigSchema } from "./config-schema.js";
32
32
  import type { CoreConfig } from "./config-schema.js";
33
- import { getTeamilyMonitor, startTeamilyMonitoring, stopTeamilyMonitoring, type TeamilyMonitor } from "./monitor.js";
33
+ import {
34
+ getTeamilyMonitor,
35
+ startTeamilyMonitoring,
36
+ stopTeamilyMonitoring,
37
+ type TeamilyMonitor,
38
+ } from "./monitor.js";
34
39
  import { normalizeTeamilyTarget, normalizeTeamilyAllowEntry } from "./normalize.js";
35
40
  import { probeTeamily } from "./probe.js";
36
41
  import { getTeamilyRuntime } from "./runtime.js";
@@ -65,6 +70,11 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
65
70
  threads: false,
66
71
  polls: false,
67
72
  },
73
+ agentPrompt: {
74
+ messageToolHints: () => [
75
+ "- To send a local file or image to the user, use MEDIA:./relative-path in your reply (e.g. MEDIA:./image.png). The path must be relative to the workspace. You can also use the message tool with media/path/filePath. Avoid absolute paths and ~ paths — they are blocked for security.",
76
+ ],
77
+ },
68
78
  reload: { configPrefixes: ["channels.teamily"] },
69
79
  setup: {
70
80
  resolveAccountId: ({ accountId, input }) => {
@@ -192,7 +202,12 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
192
202
  const fileName = media.fileName || path.basename(mediaUrl) || "media";
193
203
  const contentType = media.contentType || guessContentType(mediaUrl);
194
204
  const messageId = await sendMediaBuffer(
195
- monitor, target, media.buffer, fileName, contentType, mediaUrl,
205
+ monitor,
206
+ target,
207
+ media.buffer,
208
+ fileName,
209
+ contentType,
210
+ mediaUrl,
196
211
  );
197
212
  return { channel: "teamily" as const, messageId };
198
213
  }
@@ -373,12 +388,14 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
373
388
  if (!monitor)
374
389
  throw new Error(`Teamily monitor not running for account ${accountId}`);
375
390
  const target = normalizeTeamilyTarget(replyTarget);
376
- const replyText = (payload as Record<string, unknown>)?.text as string | undefined
377
- ?? (payload as Record<string, unknown>)?.body as string | undefined
378
- ?? "";
391
+ const replyText =
392
+ ((payload as Record<string, unknown>)?.text as string | undefined) ??
393
+ ((payload as Record<string, unknown>)?.body as string | undefined) ??
394
+ "";
379
395
 
380
396
  // Send media attachments (first with caption, rest without).
381
397
  const mediaUrls = resolveOutboundMediaUrls(payload as Record<string, unknown>);
398
+ let captionSent = false;
382
399
  const sentMedia = await sendMediaWithLeadingCaption({
383
400
  mediaUrls,
384
401
  caption: replyText,
@@ -387,6 +404,7 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
387
404
  // OpenIM doesn't support inline captions on media; send separately.
388
405
  if (caption) {
389
406
  await monitor.sendText(target, caption);
407
+ captionSent = true;
390
408
  }
391
409
  },
392
410
  onError: (error) => {
@@ -394,8 +412,8 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
394
412
  },
395
413
  });
396
414
 
397
- // If no media was sent, send text only.
398
- if (!sentMedia && replyText) {
415
+ // If no media was sent (or media failed), send text as a plain message.
416
+ if ((!sentMedia || !captionSent) && replyText) {
399
417
  log?.info?.(
400
418
  `[${accountId}] Sending reply to: ${replyTarget} (isGroup=${isGroup})`,
401
419
  );
package/src/monitor.ts CHANGED
@@ -20,6 +20,36 @@ export interface TeamilyMonitorOptions {
20
20
  type SdkModule = typeof import("@openim/client-sdk");
21
21
  type SdkInstance = ReturnType<SdkModule["getSDK"]>;
22
22
 
23
+ // The OpenIM SDK uses FileReader internally for file upload (MD5 hashing of
24
+ // chunks). FileReader is a browser-only API not available in Node.js, so we
25
+ // provide a minimal polyfill that delegates to Blob.prototype.arrayBuffer().
26
+ if (typeof globalThis.FileReader === "undefined") {
27
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
+ (globalThis as any).FileReader = class FileReader {
29
+ result: ArrayBuffer | null = null;
30
+ error: unknown = null;
31
+ readyState = 0; // 0=EMPTY, 1=LOADING, 2=DONE
32
+ onload: ((ev: { target: FileReader }) => void) | null = null;
33
+ onerror: ((ev: unknown) => void) | null = null;
34
+
35
+ readAsArrayBuffer(blob: Blob): void {
36
+ this.readyState = 1;
37
+ blob
38
+ .arrayBuffer()
39
+ .then((buffer) => {
40
+ this.result = buffer;
41
+ this.readyState = 2;
42
+ this.onload?.({ target: this });
43
+ })
44
+ .catch((err) => {
45
+ this.error = err;
46
+ this.readyState = 2;
47
+ this.onerror?.(err);
48
+ });
49
+ }
50
+ };
51
+ }
52
+
23
53
  // Lazy-loaded SDK to avoid top-level dynamic import issues
24
54
  let sdkModule: SdkModule | null = null;
25
55
  async function loadSDK() {
@@ -209,10 +239,23 @@ export class TeamilyMonitor {
209
239
  // Like Telegram's InputFile(buffer), pass a buffer and let the SDK upload.
210
240
 
211
241
  /** Send an image from a local buffer. SDK handles the upload to object storage. */
212
- async sendImageBuffer(target: TeamilyMessageTarget, buffer: Buffer, fileName: string, contentType: string): Promise<string> {
242
+ async sendImageBuffer(
243
+ target: TeamilyMessageTarget,
244
+ buffer: Buffer,
245
+ fileName: string,
246
+ contentType: string,
247
+ ): Promise<string> {
213
248
  const sdk = this.requireSdk();
214
249
  const file = new File([new Uint8Array(buffer)], fileName, { type: contentType });
215
- const picInfo = { uuid: "", type: contentType, width: 0, height: 0, size: buffer.length, url: "" };
250
+ const uuid = crypto.randomUUID();
251
+ const picInfo = {
252
+ uuid,
253
+ type: contentType,
254
+ width: 0,
255
+ height: 0,
256
+ size: buffer.length,
257
+ url: "",
258
+ };
216
259
  const created = await sdk.createImageMessageByFile({
217
260
  sourcePicture: picInfo,
218
261
  bigPicture: picInfo,
@@ -229,7 +272,12 @@ export class TeamilyMonitor {
229
272
  }
230
273
 
231
274
  /** Send a video from a local buffer. SDK handles the upload to object storage. */
232
- async sendVideoBuffer(target: TeamilyMessageTarget, buffer: Buffer, fileName: string, contentType: string): Promise<string> {
275
+ async sendVideoBuffer(
276
+ target: TeamilyMessageTarget,
277
+ buffer: Buffer,
278
+ fileName: string,
279
+ contentType: string,
280
+ ): Promise<string> {
233
281
  const sdk = this.requireSdk();
234
282
  const videoFile = new File([new Uint8Array(buffer)], fileName, { type: contentType });
235
283
  // snapshotFile is required by the SDK type but we have no thumbnail; use an empty file.
@@ -239,10 +287,10 @@ export class TeamilyMonitor {
239
287
  duration: 0,
240
288
  videoType: contentType,
241
289
  snapshotPath: "",
242
- videoUUID: "",
290
+ videoUUID: crypto.randomUUID(),
243
291
  videoUrl: "",
244
292
  videoSize: buffer.length,
245
- snapshotUUID: "",
293
+ snapshotUUID: crypto.randomUUID(),
246
294
  snapshotSize: 0,
247
295
  snapshotUrl: "",
248
296
  snapshotWidth: 0,
@@ -259,11 +307,16 @@ export class TeamilyMonitor {
259
307
  }
260
308
 
261
309
  /** Send an audio/sound from a local buffer. SDK handles the upload to object storage. */
262
- async sendAudioBuffer(target: TeamilyMessageTarget, buffer: Buffer, fileName: string, contentType: string): Promise<string> {
310
+ async sendAudioBuffer(
311
+ target: TeamilyMessageTarget,
312
+ buffer: Buffer,
313
+ fileName: string,
314
+ contentType: string,
315
+ ): Promise<string> {
263
316
  const sdk = this.requireSdk();
264
317
  const file = new File([new Uint8Array(buffer)], fileName, { type: contentType });
265
318
  const created = await sdk.createSoundMessageByFile({
266
- uuid: "",
319
+ uuid: crypto.randomUUID(),
267
320
  soundPath: "",
268
321
  sourceUrl: "",
269
322
  dataSize: buffer.length,
@@ -279,13 +332,18 @@ export class TeamilyMonitor {
279
332
  }
280
333
 
281
334
  /** Send a document/file from a local buffer. SDK handles the upload to object storage. */
282
- async sendFileBuffer(target: TeamilyMessageTarget, buffer: Buffer, fileName: string, contentType: string): Promise<string> {
335
+ async sendFileBuffer(
336
+ target: TeamilyMessageTarget,
337
+ buffer: Buffer,
338
+ fileName: string,
339
+ contentType: string,
340
+ ): Promise<string> {
283
341
  const sdk = this.requireSdk();
284
342
  const file = new File([new Uint8Array(buffer)], fileName, { type: contentType });
285
343
  const created = await sdk.createFileMessageByFile({
286
344
  filePath: "",
287
345
  fileName,
288
- uuid: "",
346
+ uuid: crypto.randomUUID(),
289
347
  sourceUrl: "",
290
348
  fileSize: buffer.length,
291
349
  file,
@@ -328,13 +386,12 @@ function convertSdkMessage(msg: MessageItem, selfUserID: string): TeamilyMessage
328
386
  // Determine whether this message @-mentions the bot
329
387
  const isAtSelf =
330
388
  contentType === CONTENT_TYPES.AT_TEXT &&
331
- (msg.atTextElem?.isAtSelf === true ||
332
- (msg.atTextElem?.atUserList ?? []).includes(selfUserID));
389
+ (msg.atTextElem?.isAtSelf === true || (msg.atTextElem?.atUserList ?? []).includes(selfUserID));
333
390
 
334
391
  return {
335
392
  serverMsgID: msg.serverMsgID || msg.clientMsgID || `${Date.now()}_${Math.random()}`,
336
393
  sendID: msg.sendID || "unknown",
337
- recvID: isGroupSession(sessionType) ? (msg.groupID || "") : (msg.recvID || selfUserID),
394
+ recvID: isGroupSession(sessionType) ? msg.groupID || "" : msg.recvID || selfUserID,
338
395
  content,
339
396
  contentType,
340
397
  sessionType,
package/src/upload.ts CHANGED
@@ -2,7 +2,7 @@ import path from "node:path";
2
2
 
3
3
  /** Returns true if the media URL is a local file path rather than a remote URL. */
4
4
  export function isLocalMediaPath(mediaUrl: string): boolean {
5
- return !(/^https?:\/\//i.test(mediaUrl));
5
+ return !/^https?:\/\//i.test(mediaUrl);
6
6
  }
7
7
 
8
8
  /** Detect the media category from a file extension. */
package/src/send.ts DELETED
@@ -1,271 +0,0 @@
1
- import { generateOperationID } from "./probe.js";
2
- import type { ResolvedTeamilyAccount, TeamilyMessageTarget } from "./types.js";
3
- import { CONTENT_TYPES, SESSION_TYPES } from "./types.js";
4
-
5
- export interface SendTeamilyMessageParams {
6
- account: ResolvedTeamilyAccount;
7
- target: TeamilyMessageTarget;
8
- text: string;
9
- replyToId?: string;
10
- fetchImpl?: typeof fetch;
11
- }
12
-
13
- export interface SendTeamilyMediaParams {
14
- account: ResolvedTeamilyAccount;
15
- target: TeamilyMessageTarget;
16
- mediaUrl: string;
17
- mediaType: "image" | "video" | "audio" | "file";
18
- caption?: string;
19
- fetchImpl?: typeof fetch;
20
- }
21
-
22
- export interface TeamilySendResult {
23
- success: boolean;
24
- messageId?: string;
25
- serverMsgID?: string;
26
- clientMsgID?: string;
27
- error?: string;
28
- }
29
-
30
- /**
31
- * Send a text message via Teamily REST API.
32
- *
33
- * @param params - Send parameters
34
- * @returns Send result with message ID or error
35
- */
36
- export async function sendMessageTeamily(
37
- params: SendTeamilyMessageParams,
38
- ): Promise<TeamilySendResult> {
39
- const { account, target, text, replyToId, fetchImpl = fetch } = params;
40
-
41
- const url = `${account.apiURL}/msg/send_msg`;
42
-
43
- const payload = buildSendMessagePayload({
44
- sendID: account.userID,
45
- target,
46
- content: { content: text },
47
- contentType: CONTENT_TYPES.TEXT,
48
- replyToId,
49
- });
50
-
51
- try {
52
- const response = await fetchImpl(url, {
53
- method: "POST",
54
- headers: {
55
- "Content-Type": "application/json",
56
- operationID: generateOperationID(),
57
- token: account.token,
58
- },
59
- body: JSON.stringify(payload),
60
- });
61
-
62
- if (!response.ok) {
63
- const errorText = await response.text();
64
- return {
65
- success: false,
66
- error: `HTTP ${response.status}: ${errorText}`,
67
- };
68
- }
69
-
70
- const data = (await response.json()) as {
71
- errCode: number;
72
- errMsg: string;
73
- data?: {
74
- serverMsgID: string;
75
- clientMsgID: string;
76
- sendTime: number;
77
- };
78
- };
79
-
80
- if (data.errCode !== 0) {
81
- return {
82
- success: false,
83
- error: data.errMsg || "Send failed",
84
- };
85
- }
86
-
87
- return {
88
- success: true,
89
- messageId: data.data?.serverMsgID,
90
- serverMsgID: data.data?.serverMsgID,
91
- clientMsgID: data.data?.clientMsgID,
92
- };
93
- } catch (error) {
94
- return {
95
- success: false,
96
- error: error instanceof Error ? error.message : String(error),
97
- };
98
- }
99
- }
100
-
101
- /**
102
- * Send a media message via Teamily REST API.
103
- *
104
- * @param params - Send parameters
105
- * @returns Send result with message ID or error
106
- */
107
- export async function sendMediaTeamily(params: SendTeamilyMediaParams): Promise<TeamilySendResult> {
108
- const { account, target, mediaUrl, mediaType, caption, fetchImpl = fetch } = params;
109
-
110
- const url = `${account.apiURL}/msg/send_msg`;
111
-
112
- let content: Record<string, unknown>;
113
- let contentType: number;
114
-
115
- switch (mediaType) {
116
- case "image":
117
- contentType = CONTENT_TYPES.PICTURE;
118
- content = {
119
- sourcePicture: {
120
- uuid: generateOperationID(),
121
- type: "public",
122
- width: 0,
123
- height: 0,
124
- url: mediaUrl,
125
- },
126
- };
127
- break;
128
-
129
- case "video":
130
- contentType = CONTENT_TYPES.VIDEO;
131
- content = {
132
- videoPath: mediaUrl,
133
- videoUUID: generateOperationID(),
134
- videoUrl: mediaUrl,
135
- videoType: "mp4",
136
- videoSize: 0,
137
- duration: 0,
138
- snapshotUUID: `${generateOperationID()}_snap`,
139
- snapshotUrl: "",
140
- snapshotSize: 0,
141
- snapshotWidth: 0,
142
- snapshotHeight: 0,
143
- };
144
- break;
145
-
146
- case "audio":
147
- contentType = CONTENT_TYPES.VOICE;
148
- content = {
149
- uuid: generateOperationID(),
150
- soundPath: mediaUrl,
151
- sourceUrl: mediaUrl,
152
- dataSize: 0,
153
- duration: 0,
154
- soundType: "mp3",
155
- };
156
- break;
157
-
158
- case "file":
159
- contentType = CONTENT_TYPES.FILE;
160
- content = {
161
- uuid: generateOperationID(),
162
- fileName: mediaUrl.split("/").pop() || "file",
163
- fileSize: 0,
164
- sourceUrl: mediaUrl,
165
- fileType: "application/octet-stream",
166
- };
167
- break;
168
-
169
- default:
170
- return {
171
- success: false,
172
- error: `Unsupported media type: ${mediaType}`,
173
- };
174
- }
175
-
176
- // Add caption to text content for media messages
177
- if (caption) {
178
- content.text = caption;
179
- }
180
-
181
- const payload = buildSendMessagePayload({
182
- sendID: account.userID,
183
- target,
184
- content,
185
- contentType,
186
- });
187
-
188
- try {
189
- const response = await fetchImpl(url, {
190
- method: "POST",
191
- headers: {
192
- "Content-Type": "application/json",
193
- operationID: generateOperationID(),
194
- token: account.token,
195
- },
196
- body: JSON.stringify(payload),
197
- });
198
-
199
- if (!response.ok) {
200
- const errorText = await response.text();
201
- return {
202
- success: false,
203
- error: `HTTP ${response.status}: ${errorText}`,
204
- };
205
- }
206
-
207
- const data = (await response.json()) as {
208
- errCode: number;
209
- errMsg: string;
210
- data?: {
211
- serverMsgID: string;
212
- clientMsgID: string;
213
- sendTime: number;
214
- };
215
- };
216
-
217
- if (data.errCode !== 0) {
218
- return {
219
- success: false,
220
- error: data.errMsg || "Send failed",
221
- };
222
- }
223
-
224
- return {
225
- success: true,
226
- messageId: data.data?.serverMsgID,
227
- serverMsgID: data.data?.serverMsgID,
228
- clientMsgID: data.data?.clientMsgID,
229
- };
230
- } catch (error) {
231
- return {
232
- success: false,
233
- error: error instanceof Error ? error.message : String(error),
234
- };
235
- }
236
- }
237
-
238
- /**
239
- * Build the send message payload for Teamily API.
240
- */
241
- function buildSendMessagePayload(params: {
242
- sendID: string;
243
- target: TeamilyMessageTarget;
244
- content: Record<string, unknown>;
245
- contentType: number;
246
- replyToId?: string;
247
- }): Record<string, unknown> {
248
- const { sendID, target, content, contentType, replyToId } = params;
249
-
250
- const payload: Record<string, unknown> = {
251
- sendID,
252
- recvID: target.type === "user" ? target.id : "",
253
- groupID: target.type === "group" ? target.id : "",
254
- content,
255
- contentType,
256
- sessionType: target.type === "group" ? SESSION_TYPES.GROUP : SESSION_TYPES.SINGLE,
257
- isOnlineOnly: false,
258
- };
259
-
260
- if (replyToId) {
261
- payload.quote = {
262
- text: "",
263
- content: {},
264
- isReact: false,
265
- userID: "",
266
- msgID: replyToId,
267
- };
268
- }
269
-
270
- return payload;
271
- }