@ascegu/teamily 1.0.5 → 1.0.7

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
@@ -23,6 +23,7 @@ openclaw channel configure teamily
23
23
  ```
24
24
 
25
25
  Required server settings:
26
+
26
27
  - `platformUrl`: Teamily platform URL (default: `http://localhost:10002`)
27
28
  - `apiURL`: Teamily REST API URL (default: `http://localhost:10002`)
28
29
  - `wsURL`: Teamily WebSocket URL (default: `ws://localhost:10001`)
package/package.json CHANGED
@@ -1,33 +1,33 @@
1
1
  {
2
2
  "name": "@ascegu/teamily",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "OpenClaw Teamily channel plugin - Team instant messaging server integration",
5
- "type": "module",
6
- "main": "index.ts",
7
- "files": [
8
- "*.ts",
9
- "*.js",
10
- "*.json",
11
- "src/",
12
- "README.md"
13
- ],
14
5
  "keywords": [
15
- "openclaw",
16
- "plugin",
17
6
  "channel",
18
- "teamily",
19
- "openim",
7
+ "communication",
20
8
  "messaging",
9
+ "openclaw",
10
+ "openim",
11
+ "plugin",
21
12
  "team",
22
- "communication"
13
+ "teamily"
23
14
  ],
24
- "author": "ascegu",
25
15
  "license": "MIT",
16
+ "author": "ascegu",
26
17
  "repository": {
27
18
  "type": "git",
28
19
  "url": "git+https://github.com/ascegu/openclaw.git",
29
20
  "directory": "extensions/teamily"
30
21
  },
22
+ "files": [
23
+ "*.ts",
24
+ "*.js",
25
+ "*.json",
26
+ "src/",
27
+ "README.md"
28
+ ],
29
+ "type": "module",
30
+ "main": "index.ts",
31
31
  "dependencies": {
32
32
  "zod": "^4.3.6"
33
33
  },
package/src/accounts.ts CHANGED
@@ -6,9 +6,7 @@ export function listTeamilyAccountIds(cfg: CoreConfig): string[] {
6
6
  if (!config?.enabled || !config.accounts) {
7
7
  return [];
8
8
  }
9
- return Object.keys(config.accounts).filter(
10
- (key) => config.accounts![key].token !== undefined
11
- );
9
+ return Object.keys(config.accounts).filter((key) => config.accounts![key].token !== undefined);
12
10
  }
13
11
 
14
12
  export function resolveDefaultTeamilyAccountId(cfg: CoreConfig): string {
@@ -18,7 +16,7 @@ export function resolveDefaultTeamilyAccountId(cfg: CoreConfig): string {
18
16
 
19
17
  export function resolveTeamilyAccount(
20
18
  cfg: CoreConfig,
21
- accountId?: string | null
19
+ accountId?: string | null,
22
20
  ): ResolvedTeamilyAccount {
23
21
  const config = cfg.channels?.teamily;
24
22
  if (!config?.enabled) {
@@ -47,5 +45,6 @@ export function resolveTeamilyAccount(
47
45
  token: account.token,
48
46
  nickname: account.nickname,
49
47
  faceURL: account.faceURL,
48
+ dm: account.dm ?? config.dm,
50
49
  };
51
50
  }
package/src/channel.ts CHANGED
@@ -3,30 +3,31 @@ import {
3
3
  buildChannelConfigSchema,
4
4
  DEFAULT_ACCOUNT_ID,
5
5
  deleteAccountFromConfigSection,
6
- formatPairingApproveHint,
7
6
  normalizeAccountId,
8
7
  PAIRING_APPROVED_MESSAGE,
9
8
  setAccountEnabledInConfigSection,
10
9
  type ChannelPlugin,
11
10
  type ChannelOutboundContext,
12
- type ChannelOutboundAdapter,
13
- type ChannelStatusAdapter,
14
11
  type ChannelStatusIssue,
15
12
  } from "openclaw/plugin-sdk";
16
- import { TeamilyConfigSchema } from "./config-schema.js";
13
+ import {
14
+ buildAccountScopedDmSecurityPolicy,
15
+ createScopedAccountConfigAccessors,
16
+ } from "openclaw/plugin-sdk/compat";
17
17
  import {
18
18
  listTeamilyAccountIds,
19
19
  resolveDefaultTeamilyAccountId,
20
20
  resolveTeamilyAccount,
21
- type ResolvedTeamilyAccount,
22
21
  } from "./accounts.js";
23
- import { probeTeamily } from "./probe.js";
24
- import { sendMessageTeamily, sendMediaTeamily } from "./send.js";
22
+ import { TeamilyConfigSchema } from "./config-schema.js";
23
+ import type { CoreConfig } from "./config-schema.js";
25
24
  import { startTeamilyMonitoring, stopTeamilyMonitoring } from "./monitor.js";
26
25
  import { normalizeTeamilyTarget, normalizeTeamilyAllowEntry } from "./normalize.js";
27
- import { SESSION_TYPES } from "./types.js";
26
+ import { probeTeamily } from "./probe.js";
28
27
  import { getTeamilyRuntime } from "./runtime.js";
29
- import type { CoreConfig } from "./config-schema.js";
28
+ import { sendMessageTeamily, sendMediaTeamily } from "./send.js";
29
+ import type { ResolvedTeamilyAccount } from "./types.js";
30
+ import { SESSION_TYPES } from "./types.js";
30
31
 
31
32
  const meta = {
32
33
  id: "teamily",
@@ -39,33 +40,42 @@ const meta = {
39
40
  quickstartAllowFrom: true,
40
41
  };
41
42
 
42
- const capabilities = {
43
- chatTypes: ["direct", "group"] as const,
44
- media: true,
45
- reactions: true,
46
- threads: false,
47
- polls: false,
48
- streaming: false,
49
- };
43
+ const teamilyConfigAccessors = createScopedAccountConfigAccessors({
44
+ resolveAccount: ({ cfg, accountId }) => resolveTeamilyAccount(cfg as CoreConfig, accountId),
45
+ resolveAllowFrom: (account) => account.dm?.allowFrom,
46
+ formatAllowFrom: (allowFrom) => allowFrom.map((id) => String(id)),
47
+ });
50
48
 
51
49
  export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
52
50
  id: "teamily",
53
51
  meta,
54
- capabilities,
55
- onboarding: {
56
- promptAccountId,
57
- resolveAccountId,
52
+ capabilities: {
53
+ chatTypes: ["direct", "group"],
54
+ media: true,
55
+ reactions: false,
56
+ threads: false,
57
+ polls: false,
58
+ },
59
+ reload: { configPrefixes: ["channels.teamily"] },
60
+ setup: {
61
+ resolveAccountId: ({ accountId, input }) => {
62
+ if (accountId) return accountId;
63
+ if (input?.name) return normalizeAccountId(String(input.name));
64
+ return DEFAULT_ACCOUNT_ID;
65
+ },
58
66
  applyAccountName: ({ cfg, accountId, name }) =>
59
67
  applyAccountNameToChannelSection({
60
- cfg: cfg as CoreConfig,
61
- sectionKey: "teamily",
68
+ cfg,
69
+ channelKey: "teamily",
62
70
  accountId,
63
71
  name,
64
- allowTopLevel: true,
65
72
  }),
66
73
  applyAccountConfig: ({ cfg, accountId, input }) =>
67
- applyTeamilyAccountConfig({ cfg: cfg as CoreConfig, accountId, input }),
68
- resolveBindingAccountId: ({ cfg }) => resolveDefaultTeamilyAccountId(cfg as CoreConfig),
74
+ applyTeamilyAccountConfig({
75
+ cfg: cfg as CoreConfig,
76
+ accountId,
77
+ input: input as Record<string, unknown>,
78
+ }),
69
79
  },
70
80
  pairing: {
71
81
  idLabel: "teamilyUserId",
@@ -94,8 +104,7 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
94
104
  configSchema: buildChannelConfigSchema(TeamilyConfigSchema),
95
105
  config: {
96
106
  listAccountIds: (cfg) => listTeamilyAccountIds(cfg as CoreConfig),
97
- resolveAccount: (cfg, accountId) =>
98
- resolveTeamilyAccount(cfg as CoreConfig, accountId),
107
+ resolveAccount: (cfg, accountId) => resolveTeamilyAccount(cfg as CoreConfig, accountId),
99
108
  defaultAccountId: (cfg) => resolveDefaultTeamilyAccountId(cfg as CoreConfig),
100
109
  setAccountEnabled: ({ cfg, accountId, enabled }) =>
101
110
  setAccountEnabledInConfigSection({
@@ -110,13 +119,7 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
110
119
  cfg: cfg as CoreConfig,
111
120
  sectionKey: "teamily",
112
121
  accountId,
113
- clearBaseFields: [
114
- "name",
115
- "userID",
116
- "token",
117
- "nickname",
118
- "faceURL",
119
- ],
122
+ clearBaseFields: ["name", "userID", "token", "nickname", "faceURL"],
120
123
  }),
121
124
  isConfigured: (account) => !!account.token,
122
125
  describeAccount: (account) => ({
@@ -125,22 +128,38 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
125
128
  enabled: account.enabled,
126
129
  configured: !!account.token,
127
130
  }),
128
- resolveAllowFrom: (cfg, accountId) => {
129
- // Return empty array - allowFrom needs to be manually configured
130
- return [];
131
- },
132
- formatAllowFrom: (cfg, allowFrom) => {
133
- return allowFrom.map((id) => id.toString());
134
- },
135
- resolveDefaultTo: (cfg) => {
136
- // No default target - user must specify
137
- return undefined;
131
+ ...teamilyConfigAccessors,
132
+ },
133
+ security: {
134
+ resolveDmPolicy: ({ cfg, accountId, account }) => {
135
+ return buildAccountScopedDmSecurityPolicy({
136
+ cfg: cfg as CoreConfig,
137
+ channelKey: "teamily",
138
+ accountId,
139
+ fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
140
+ policy: account.dm?.policy,
141
+ allowFrom: account.dm?.allowFrom ?? [],
142
+ allowFromPathSuffix: "dm.",
143
+ normalizeEntry: (raw) => normalizeTeamilyAllowEntry(raw),
144
+ });
138
145
  },
139
146
  },
140
147
  outbound: {
148
+ deliveryMode: "gateway",
149
+ resolveTarget: ({ to }) => {
150
+ if (!to?.trim()) {
151
+ return { ok: false, error: new Error("Teamily requires --to <userId|group:groupId>") };
152
+ }
153
+ try {
154
+ const target = normalizeTeamilyTarget(to);
155
+ return { ok: true, to: target.id };
156
+ } catch (err) {
157
+ return { ok: false, error: err instanceof Error ? err : new Error(String(err)) };
158
+ }
159
+ },
141
160
  sendText: async (ctx: ChannelOutboundContext) => {
142
161
  const { to, text, accountId } = ctx;
143
- const account = resolveTeamilyAccount(ctx.cfg, accountId);
162
+ const account = resolveTeamilyAccount(ctx.cfg as CoreConfig, accountId);
144
163
  const target = normalizeTeamilyTarget(to);
145
164
 
146
165
  const result = await sendMessageTeamily({
@@ -154,11 +173,15 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
154
173
  throw new Error(result.error || "Failed to send message");
155
174
  }
156
175
 
157
- return { messageId: result.messageId };
176
+ return { channel: "teamily" as const, messageId: result.messageId ?? "" };
158
177
  },
159
178
  sendMedia: async (ctx: ChannelOutboundContext) => {
160
- const { to, mediaUrl, text, accountId } = ctx;
161
- const account = resolveTeamilyAccount(ctx.cfg, accountId);
179
+ const { to, text, accountId } = ctx;
180
+ const mediaUrl = ctx.mediaUrl;
181
+ if (!mediaUrl) {
182
+ throw new Error("Media URL is required");
183
+ }
184
+ const account = resolveTeamilyAccount(ctx.cfg as CoreConfig, accountId);
162
185
  const target = normalizeTeamilyTarget(to);
163
186
 
164
187
  // Determine media type from URL or assume image
@@ -166,7 +189,11 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
166
189
  const urlLower = mediaUrl.toLowerCase();
167
190
  if (urlLower.endsWith(".mp4") || urlLower.endsWith(".mov") || urlLower.endsWith(".webm")) {
168
191
  mediaType = "video";
169
- } else if (urlLower.endsWith(".mp3") || urlLower.endsWith(".m4a") || urlLower.endsWith(".wav")) {
192
+ } else if (
193
+ urlLower.endsWith(".mp3") ||
194
+ urlLower.endsWith(".m4a") ||
195
+ urlLower.endsWith(".wav")
196
+ ) {
170
197
  mediaType = "audio";
171
198
  } else if (
172
199
  urlLower.endsWith(".pdf") ||
@@ -189,80 +216,54 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
189
216
  throw new Error(result.error || "Failed to send media");
190
217
  }
191
218
 
192
- return { messageId: result.messageId };
193
- },
194
- resolveTarget: (raw) => {
195
- return normalizeTeamilyTarget(raw).id;
219
+ return { channel: "teamily" as const, messageId: result.messageId ?? "" };
196
220
  },
197
221
  },
198
222
  status: {
199
- probeAccount: async (cfg, accountId) => {
200
- const account = resolveTeamilyAccount(cfg as CoreConfig, accountId);
223
+ probeAccount: async ({ account }) => {
201
224
  const result = await probeTeamily(account);
202
-
203
225
  if (!result.connected) {
204
226
  return {
205
- connected: false,
227
+ ok: false,
206
228
  error: result.error || "Failed to connect to Teamily server",
207
229
  };
208
230
  }
209
-
210
- return { connected: true };
211
- },
212
- buildAccountSnapshot: (cfg, accountId) => {
213
- const account = resolveTeamilyAccount(cfg as CoreConfig, accountId);
214
- return {
215
- accountId,
216
- name: account.nickname || account.userID,
217
- enabled: account.enabled,
218
- configured: !!account.token,
219
- };
231
+ return { ok: true };
220
232
  },
221
- collectStatusIssues: async (cfg, accountId) => {
233
+ buildAccountSnapshot: ({ account, runtime, probe }) => ({
234
+ accountId: account.accountId,
235
+ name: account.nickname || account.userID,
236
+ enabled: account.enabled,
237
+ configured: !!account.token,
238
+ running: runtime?.running ?? false,
239
+ lastStartAt: runtime?.lastStartAt ?? null,
240
+ lastStopAt: runtime?.lastStopAt ?? null,
241
+ lastError: runtime?.lastError ?? null,
242
+ probe,
243
+ }),
244
+ collectStatusIssues: (accounts) => {
222
245
  const issues: ChannelStatusIssue[] = [];
223
- const account = resolveTeamilyAccount(cfg as CoreConfig, accountId);
224
-
225
- if (!account.token) {
226
- issues.push({
227
- channel: "teamily",
228
- accountId,
229
- kind: "config",
230
- message: "User token is not configured",
231
- fix: "Run `openclaw channel configure teamily` to set up authentication",
232
- });
233
- }
234
-
235
- if (!account.apiURL || account.apiURL === "http://localhost:10002") {
236
- issues.push({
237
- channel: "teamily",
238
- accountId,
239
- kind: "config",
240
- message: "Teamily API URL is set to default localhost",
241
- fix: "Update the API URL to your Teamily server address",
242
- });
243
- }
244
-
245
- const probeResult = await probeTeamily(account);
246
- if (!probeResult.connected) {
247
- issues.push({
248
- channel: "teamily",
249
- accountId,
250
- kind: "runtime",
251
- message: probeResult.error || "Cannot connect to Teamily server",
252
- fix: "Check that the Teamily server is running and accessible",
253
- });
246
+ for (const snap of accounts) {
247
+ if (snap.lastError) {
248
+ issues.push({
249
+ channel: "teamily",
250
+ accountId: snap.accountId,
251
+ kind: "runtime",
252
+ message: snap.lastError,
253
+ fix: "Check that the Teamily server is running and accessible",
254
+ });
255
+ }
254
256
  }
255
-
256
257
  return issues;
257
258
  },
258
259
  },
259
260
  gateway: {
260
261
  startAccount: async (ctx) => {
261
- const { cfg, accountId, account, log } = ctx;
262
+ const { accountId, account, log } = ctx;
262
263
 
263
264
  if (!account.token) {
264
265
  log?.warn?.(`Teamily account ${accountId} not configured (missing token)`);
265
- return { stop: () => {} };
266
+ return;
266
267
  }
267
268
 
268
269
  log?.info?.(`Starting Teamily channel (account: ${accountId})`);
@@ -291,7 +292,7 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
291
292
  To: account.userID,
292
293
  SessionKey: sessionKey,
293
294
  AccountId: accountId,
294
- OriginatingChannel: "teamily" as any,
295
+ OriginatingChannel: "teamily" as const,
295
296
  OriginatingTo: from,
296
297
  ChatType: isGroup ? "group" : "direct",
297
298
  MediaUrl: mediaUrl,
@@ -321,91 +322,50 @@ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
321
322
  stopTeamilyMonitoring(accountId);
322
323
  });
323
324
 
324
- // Return a promise that never resolves (monitor runs indefinitely)
325
- return new Promise<void>(() => {});
325
+ // Block until aborted monitor runs indefinitely
326
+ await new Promise<void>((resolve) => {
327
+ ctx.abortSignal.addEventListener("abort", () => resolve());
328
+ });
326
329
  },
327
330
  },
328
331
  };
329
332
 
330
- /**
331
- * Normalize account ID for Teamily.
332
- */
333
- function promptAccountId(): string {
334
- return DEFAULT_ACCOUNT_ID;
335
- }
336
-
337
- function resolveAccountId(params: {
338
- cfg: CoreConfig;
339
- accountId?: string;
340
- input?: { name?: string };
341
- }): string {
342
- const { cfg, accountId, input } = params;
343
- if (accountId) {
344
- return accountId;
345
- }
346
- if (input?.name) {
347
- return normalizeAccountId(input.name);
348
- }
349
- const accountIds = listTeamilyAccountIds(cfg);
350
- return accountIds[0] || DEFAULT_ACCOUNT_ID;
351
- }
352
-
353
- /**
354
- * Apply Teamily account configuration.
355
- */
356
333
  function applyTeamilyAccountConfig(params: {
357
334
  cfg: CoreConfig;
358
335
  accountId: string;
359
336
  input: Record<string, unknown>;
360
337
  }): CoreConfig {
361
338
  const { cfg, accountId, input } = params;
362
- const existing = cfg.channels?.teamily || { enabled: false, server: {}, accounts: {} };
339
+ const existing = cfg.channels?.teamily;
340
+ const server = existing?.server ?? { platformUrl: "", apiURL: "", wsURL: "" };
341
+ const accounts = existing?.accounts ?? {};
363
342
 
364
343
  const accountUpdate: Record<string, unknown> = {};
365
- if (input.userID) {
366
- accountUpdate.userID = String(input.userID);
367
- }
368
- if (input.token) {
369
- accountUpdate.token = String(input.token);
370
- }
371
- if (input.nickname) {
372
- accountUpdate.nickname = String(input.nickname);
373
- }
374
- if (input.faceURL) {
375
- accountUpdate.faceURL = String(input.faceURL);
376
- }
344
+ if (input.userID) accountUpdate.userID = String(input.userID);
345
+ if (input.token) accountUpdate.token = String(input.token);
346
+ if (input.nickname) accountUpdate.nickname = String(input.nickname);
347
+ if (input.faceURL) accountUpdate.faceURL = String(input.faceURL);
377
348
 
378
- // Server configuration from input or existing
379
- const serverUpdate: Record<string, unknown> = {};
380
- if (input.platformUrl) {
381
- serverUpdate.platformUrl = String(input.platformUrl);
382
- }
383
- if (input.apiURL) {
384
- serverUpdate.apiURL = String(input.apiURL);
385
- }
386
- if (input.wsURL) {
387
- serverUpdate.wsURL = String(input.wsURL);
388
- }
349
+ const serverUpdate: Record<string, string> = {};
350
+ if (input.platformUrl) serverUpdate.platformUrl = String(input.platformUrl);
351
+ if (input.apiURL) serverUpdate.apiURL = String(input.apiURL);
352
+ if (input.wsURL) serverUpdate.wsURL = String(input.wsURL);
389
353
 
390
354
  return {
391
355
  ...cfg,
392
356
  channels: {
393
357
  ...cfg.channels,
394
358
  teamily: {
395
- ...existing,
396
359
  enabled: true,
397
- server: {
398
- ...existing.server,
399
- ...serverUpdate,
400
- },
360
+ server: { ...server, ...serverUpdate },
401
361
  accounts: {
402
- ...existing.accounts,
362
+ ...accounts,
403
363
  [accountId]: {
404
- ...(existing.accounts?.[accountId] || {}),
364
+ ...accounts[accountId],
405
365
  ...accountUpdate,
406
366
  },
407
367
  },
408
368
  },
409
369
  },
410
- };
370
+ } as CoreConfig;
411
371
  }
@@ -1,63 +1,46 @@
1
- import { z } from "zod";
2
1
  import type { ChannelConfigSchema } from "openclaw/plugin-sdk";
3
2
  import { buildChannelConfigSchema } from "openclaw/plugin-sdk";
3
+ import { z } from "zod";
4
4
  import type { TeamilyConfig } from "./types.js";
5
5
 
6
6
  // Server configuration schema
7
7
  export const TeamilyServerConfigSchema = z.object({
8
- platformUrl: z
9
- .string()
10
- .url()
11
- .default("http://localhost:10002")
12
- .describe("Teamily platform URL"),
13
- apiURL: z
14
- .string()
15
- .url()
16
- .default("http://localhost:10002")
17
- .describe("Teamily REST API URL"),
18
- wsURL: z
19
- .string()
20
- .url()
21
- .default("ws://localhost:10001")
22
- .describe("Teamily WebSocket URL"),
8
+ platformUrl: z.string().url().default("http://localhost:10002").describe("Teamily platform URL"),
9
+ apiURL: z.string().url().default("http://localhost:10002").describe("Teamily REST API URL"),
10
+ wsURL: z.string().url().default("ws://localhost:10001").describe("Teamily WebSocket URL"),
11
+ });
12
+
13
+ // DM security configuration schema
14
+ export const TeamilyDmConfigSchema = z.object({
15
+ policy: z.string().optional().describe("DM security policy (pairing, allowlist, open)"),
16
+ allowFrom: z
17
+ .array(z.union([z.string(), z.number()]))
18
+ .optional()
19
+ .describe("List of allowed sender IDs"),
23
20
  });
24
21
 
25
22
  // User account configuration schema
26
23
  export const TeamilyUserAccountSchema = z.object({
27
- userID: z
28
- .string()
29
- .min(1)
30
- .describe("User ID for the bot account"),
31
- token: z
32
- .string()
33
- .min(1)
34
- .describe("User token for authentication"),
35
- nickname: z
36
- .string()
37
- .optional()
38
- .describe("Display nickname for the bot"),
39
- faceURL: z
40
- .string()
41
- .url()
42
- .optional()
43
- .describe("Avatar URL for the bot"),
24
+ userID: z.string().min(1).describe("User ID for the bot account"),
25
+ token: z.string().min(1).describe("User token for authentication"),
26
+ nickname: z.string().optional().describe("Display nickname for the bot"),
27
+ faceURL: z.string().url().optional().describe("Avatar URL for the bot"),
28
+ dm: TeamilyDmConfigSchema.optional().describe("Per-account DM security settings"),
44
29
  });
45
30
 
46
31
  // Main Teamily configuration schema
47
32
  export const TeamilyConfigSchema = z.object({
48
- enabled: z
49
- .boolean()
50
- .default(true)
51
- .describe("Enable Teamily channel"),
33
+ enabled: z.boolean().default(true).describe("Enable Teamily channel"),
52
34
  server: TeamilyServerConfigSchema.describe("Teamily server configuration"),
53
35
  accounts: z
54
36
  .record(z.string(), TeamilyUserAccountSchema)
55
37
  .default({})
56
38
  .describe("Teamily bot accounts"),
39
+ dm: TeamilyDmConfigSchema.optional().describe("Channel-level DM security settings"),
57
40
  });
58
41
 
59
42
  export const TeamilyChannelConfigSchema = buildChannelConfigSchema(
60
- TeamilyConfigSchema
43
+ TeamilyConfigSchema,
61
44
  ) as ChannelConfigSchema;
62
45
 
63
46
  export type CoreConfig = {
package/src/monitor.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { generateOperationID } from "./probe.js";
1
2
  import type {
2
3
  ResolvedTeamilyAccount,
3
4
  TeamilyMessage,
@@ -6,7 +7,6 @@ import type {
6
7
  TeamilyAudioContent,
7
8
  } from "./types.js";
8
9
  import { CONTENT_TYPES, SESSION_TYPES } from "./types.js";
9
- import { generateOperationID } from "./probe.js";
10
10
 
11
11
  const WS_REQ = {
12
12
  LOGIN: 1001,
@@ -80,8 +80,17 @@ export class TeamilyMonitor {
80
80
  }
81
81
 
82
82
  if (this.ws) {
83
- this.ws.close(1000, "Monitoring stopped");
83
+ const ws = this.ws;
84
+ ws.onopen = null;
85
+ ws.onmessage = null;
86
+ ws.onerror = null;
87
+ ws.onclose = null;
84
88
  this.ws = null;
89
+ try {
90
+ ws.close(1000, "Monitoring stopped");
91
+ } catch {
92
+ // Ignore – socket may already be closed.
93
+ }
85
94
  }
86
95
 
87
96
  this.setState("disconnected");
@@ -128,13 +137,15 @@ export class TeamilyMonitor {
128
137
  return;
129
138
  }
130
139
  try {
131
- this.ws.send(JSON.stringify({
132
- reqIdentifier: WS_REQ.LOGIN,
133
- operationID: generateOperationID(),
134
- sendID: this.account.userID,
135
- token: this.account.token,
136
- platformID: 5,
137
- }));
140
+ this.ws.send(
141
+ JSON.stringify({
142
+ reqIdentifier: WS_REQ.LOGIN,
143
+ operationID: generateOperationID(),
144
+ sendID: this.account.userID,
145
+ token: this.account.token,
146
+ platformID: 5,
147
+ }),
148
+ );
138
149
  } catch (error) {
139
150
  this.handleError(error);
140
151
  }
@@ -201,15 +212,28 @@ export class TeamilyMonitor {
201
212
 
202
213
  /**
203
214
  * Handle WebSocket error.
215
+ * Detaches event handlers before closing to prevent recursive calls
216
+ * (ws.close() on an errored socket can re-fire onerror → stack overflow).
204
217
  */
205
218
  private handleError(error: unknown): void {
206
219
  const errorMessage = error instanceof Error ? error.message : String(error);
207
220
  this.setState("error", errorMessage);
208
221
 
209
- // Close the connection so it can be re-established
210
- if (this.ws) {
211
- this.ws.close();
222
+ // Detach handlers and grab ref before nulling, so close() cannot recurse.
223
+ const ws = this.ws;
224
+ if (ws) {
225
+ ws.onopen = null;
226
+ ws.onmessage = null;
227
+ ws.onerror = null;
228
+ ws.onclose = null;
212
229
  this.ws = null;
230
+ try {
231
+ ws.close();
232
+ } catch {
233
+ // Ignore – socket may already be closed/invalid.
234
+ }
235
+ // onclose was detached, so manually trigger reconnect logic.
236
+ this.handleClose();
213
237
  }
214
238
  }
215
239
 
@@ -259,12 +283,14 @@ export class TeamilyMonitor {
259
283
  private sendPing(): void {
260
284
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
261
285
  try {
262
- this.ws.send(JSON.stringify({
263
- reqIdentifier: WS_REQ.HEARTBEAT,
264
- operationID: generateOperationID(),
265
- sendID: this.account.userID,
266
- sendTime: Date.now(),
267
- }));
286
+ this.ws.send(
287
+ JSON.stringify({
288
+ reqIdentifier: WS_REQ.HEARTBEAT,
289
+ operationID: generateOperationID(),
290
+ sendID: this.account.userID,
291
+ sendTime: Date.now(),
292
+ }),
293
+ );
268
294
  } catch (error) {
269
295
  console.error("Teamily ping failed:", error);
270
296
  }
@@ -291,25 +317,17 @@ export class TeamilyMonitor {
291
317
  * Parse raw OpenIM message content into normalized internal format.
292
318
  * OpenIM text messages use `{ content: "text" }`, not `{ text: "..." }`.
293
319
  */
294
- function parseMessageContent(
295
- raw: unknown,
296
- contentType: number,
297
- ): TeamilyMessage["content"] {
320
+ function parseMessageContent(raw: unknown, contentType: number): TeamilyMessage["content"] {
298
321
  if (!raw) {
299
322
  return {};
300
323
  }
301
324
 
302
- const obj = (
303
- typeof raw === "string" ? JSON.parse(raw) : raw
304
- ) as Record<string, unknown>;
325
+ const obj = (typeof raw === "string" ? JSON.parse(raw) : raw) as Record<string, unknown>;
305
326
 
306
327
  switch (contentType) {
307
328
  case CONTENT_TYPES.TEXT:
308
329
  return {
309
- text:
310
- typeof obj.content === "string"
311
- ? obj.content
312
- : String(obj.content ?? ""),
330
+ text: typeof obj.content === "string" ? obj.content : String(obj.content ?? ""),
313
331
  };
314
332
  case CONTENT_TYPES.PICTURE:
315
333
  return { picture: obj as unknown as TeamilyPictureContent };
@@ -333,7 +351,7 @@ const monitors = new Map<string, TeamilyMonitor>();
333
351
  export function startTeamilyMonitoring(
334
352
  account: ResolvedTeamilyAccount,
335
353
  onMessage: TeamilyMessageHandler,
336
- onStateChange?: (state: TeamilyConnectionState, error?: string) => void
354
+ onStateChange?: (state: TeamilyConnectionState, error?: string) => void,
337
355
  ): () => void {
338
356
  const monitor = new TeamilyMonitor({
339
357
  account,
package/src/normalize.ts CHANGED
@@ -25,13 +25,8 @@ export function normalizeTeamilyTarget(raw: string): TeamilyMessageTarget {
25
25
  : trimmed;
26
26
 
27
27
  // Group target
28
- if (
29
- withoutPrefix.startsWith("group:") ||
30
- withoutPrefix.startsWith("g:")
31
- ) {
32
- const groupId = withoutPrefix
33
- .replace(/^(group:|g:)/i, "")
34
- .trim();
28
+ if (withoutPrefix.startsWith("group:") || withoutPrefix.startsWith("g:")) {
29
+ const groupId = withoutPrefix.replace(/^(group:|g:)/i, "").trim();
35
30
  if (!groupId) {
36
31
  throw new Error("Group ID cannot be empty");
37
32
  }
@@ -39,9 +34,7 @@ export function normalizeTeamilyTarget(raw: string): TeamilyMessageTarget {
39
34
  }
40
35
 
41
36
  // User target
42
- const userId = withoutPrefix
43
- .replace(/^(user:|u:)/i, "")
44
- .trim();
37
+ const userId = withoutPrefix.replace(/^(user:|u:)/i, "").trim();
45
38
  if (!userId) {
46
39
  throw new Error("User ID cannot be empty");
47
40
  }
@@ -79,8 +72,13 @@ export function looksLikeTeamilyTargetId(raw: string): boolean {
79
72
  const lowered = trimmed.toLowerCase();
80
73
 
81
74
  // Check for explicit prefixes
82
- if (lowered.startsWith("teamily:") || lowered.startsWith("user:") || lowered.startsWith("u:") ||
83
- lowered.startsWith("group:") || lowered.startsWith("g:")) {
75
+ if (
76
+ lowered.startsWith("teamily:") ||
77
+ lowered.startsWith("user:") ||
78
+ lowered.startsWith("u:") ||
79
+ lowered.startsWith("group:") ||
80
+ lowered.startsWith("g:")
81
+ ) {
84
82
  return true;
85
83
  }
86
84
 
package/src/probe.ts CHANGED
@@ -18,7 +18,7 @@ export function generateOperationID(): string {
18
18
  */
19
19
  export async function probeTeamily(
20
20
  account: ResolvedTeamilyAccount,
21
- fetchImpl: typeof fetch = fetch
21
+ fetchImpl: typeof fetch = fetch,
22
22
  ): Promise<TeamilyProbeResult> {
23
23
  try {
24
24
  const url = `${account.apiURL}/user/get_users_info`;
@@ -27,8 +27,8 @@ export async function probeTeamily(
27
27
  method: "POST",
28
28
  headers: {
29
29
  "Content-Type": "application/json",
30
- "operationID": generateOperationID(),
31
- "token": account.token,
30
+ operationID: generateOperationID(),
31
+ token: account.token,
32
32
  },
33
33
  body: JSON.stringify({
34
34
  userIDs: [account.userID],
@@ -44,7 +44,7 @@ export async function probeTeamily(
44
44
  };
45
45
  }
46
46
 
47
- const data = await response.json() as {
47
+ const data = (await response.json()) as {
48
48
  errCode: number;
49
49
  errMsg: string;
50
50
  data?: Array<{ userID: string }>;
package/src/runtime.ts CHANGED
@@ -1,14 +1,6 @@
1
1
  import type { PluginRuntime } from "openclaw/plugin-sdk";
2
+ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
2
3
 
3
- let teamilyRuntime: PluginRuntime | null = null;
4
-
5
- export function setTeamilyRuntime(runtime: PluginRuntime): void {
6
- teamilyRuntime = runtime;
7
- }
8
-
9
- export function getTeamilyRuntime(): PluginRuntime {
10
- if (!teamilyRuntime) {
11
- throw new Error("Teamily runtime not initialized");
12
- }
13
- return teamilyRuntime;
14
- }
4
+ const { setRuntime: setTeamilyRuntime, getRuntime: getTeamilyRuntime } =
5
+ createPluginRuntimeStore<PluginRuntime>("Teamily runtime not initialized");
6
+ export { getTeamilyRuntime, setTeamilyRuntime };
package/src/send.ts CHANGED
@@ -1,6 +1,6 @@
1
+ import { generateOperationID } from "./probe.js";
1
2
  import type { ResolvedTeamilyAccount, TeamilyMessageTarget } from "./types.js";
2
3
  import { CONTENT_TYPES, SESSION_TYPES } from "./types.js";
3
- import { generateOperationID } from "./probe.js";
4
4
 
5
5
  export interface SendTeamilyMessageParams {
6
6
  account: ResolvedTeamilyAccount;
@@ -34,7 +34,7 @@ export interface TeamilySendResult {
34
34
  * @returns Send result with message ID or error
35
35
  */
36
36
  export async function sendMessageTeamily(
37
- params: SendTeamilyMessageParams
37
+ params: SendTeamilyMessageParams,
38
38
  ): Promise<TeamilySendResult> {
39
39
  const { account, target, text, replyToId, fetchImpl = fetch } = params;
40
40
 
@@ -53,8 +53,8 @@ export async function sendMessageTeamily(
53
53
  method: "POST",
54
54
  headers: {
55
55
  "Content-Type": "application/json",
56
- "operationID": generateOperationID(),
57
- "token": account.token,
56
+ operationID: generateOperationID(),
57
+ token: account.token,
58
58
  },
59
59
  body: JSON.stringify(payload),
60
60
  });
@@ -67,7 +67,7 @@ export async function sendMessageTeamily(
67
67
  };
68
68
  }
69
69
 
70
- const data = await response.json() as {
70
+ const data = (await response.json()) as {
71
71
  errCode: number;
72
72
  errMsg: string;
73
73
  data?: {
@@ -104,9 +104,7 @@ export async function sendMessageTeamily(
104
104
  * @param params - Send parameters
105
105
  * @returns Send result with message ID or error
106
106
  */
107
- export async function sendMediaTeamily(
108
- params: SendTeamilyMediaParams
109
- ): Promise<TeamilySendResult> {
107
+ export async function sendMediaTeamily(params: SendTeamilyMediaParams): Promise<TeamilySendResult> {
110
108
  const { account, target, mediaUrl, mediaType, caption, fetchImpl = fetch } = params;
111
109
 
112
110
  const url = `${account.apiURL}/msg/send_msg`;
@@ -192,8 +190,8 @@ export async function sendMediaTeamily(
192
190
  method: "POST",
193
191
  headers: {
194
192
  "Content-Type": "application/json",
195
- "operationID": generateOperationID(),
196
- "token": account.token,
193
+ operationID: generateOperationID(),
194
+ token: account.token,
197
195
  },
198
196
  body: JSON.stringify(payload),
199
197
  });
@@ -206,7 +204,7 @@ export async function sendMediaTeamily(
206
204
  };
207
205
  }
208
206
 
209
- const data = await response.json() as {
207
+ const data = (await response.json()) as {
210
208
  errCode: number;
211
209
  errMsg: string;
212
210
  data?: {
package/src/types.ts CHANGED
@@ -6,17 +6,24 @@ export interface TeamilyServerConfig {
6
6
  wsURL: string;
7
7
  }
8
8
 
9
+ export interface TeamilyDmConfig {
10
+ policy?: string;
11
+ allowFrom?: Array<string | number>;
12
+ }
13
+
9
14
  export interface TeamilyUserAccount {
10
15
  userID: string;
11
16
  token: string;
12
17
  nickname?: string;
13
18
  faceURL?: string;
19
+ dm?: TeamilyDmConfig;
14
20
  }
15
21
 
16
22
  export interface TeamilyConfig {
17
23
  enabled: boolean;
18
24
  server: TeamilyServerConfig;
19
25
  accounts: Record<string, TeamilyUserAccount>;
26
+ dm?: TeamilyDmConfig;
20
27
  }
21
28
 
22
29
  export interface ResolvedTeamilyAccount {
@@ -29,6 +36,7 @@ export interface ResolvedTeamilyAccount {
29
36
  token: string;
30
37
  nickname?: string;
31
38
  faceURL?: string;
39
+ dm?: TeamilyDmConfig;
32
40
  }
33
41
 
34
42
  export interface TeamilyMessageTarget {