@ascegu/teamily 1.0.3 → 1.0.5

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.
@@ -6,4 +6,4 @@
6
6
  "additionalProperties": false,
7
7
  "properties": {}
8
8
  }
9
- }
9
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ascegu/teamily",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "OpenClaw Teamily channel plugin - Team instant messaging server integration",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -8,6 +8,7 @@
8
8
  "*.ts",
9
9
  "*.js",
10
10
  "*.json",
11
+ "src/",
11
12
  "README.md"
12
13
  ],
13
14
  "keywords": [
@@ -0,0 +1,51 @@
1
+ import type { CoreConfig } from "./config-schema.js";
2
+ import type { ResolvedTeamilyAccount } from "./types.js";
3
+
4
+ export function listTeamilyAccountIds(cfg: CoreConfig): string[] {
5
+ const config = cfg.channels?.teamily;
6
+ if (!config?.enabled || !config.accounts) {
7
+ return [];
8
+ }
9
+ return Object.keys(config.accounts).filter(
10
+ (key) => config.accounts![key].token !== undefined
11
+ );
12
+ }
13
+
14
+ export function resolveDefaultTeamilyAccountId(cfg: CoreConfig): string {
15
+ const accountIds = listTeamilyAccountIds(cfg);
16
+ return accountIds[0] || "default";
17
+ }
18
+
19
+ export function resolveTeamilyAccount(
20
+ cfg: CoreConfig,
21
+ accountId?: string | null
22
+ ): ResolvedTeamilyAccount {
23
+ const config = cfg.channels?.teamily;
24
+ if (!config?.enabled) {
25
+ throw new Error("Teamily channel is not enabled");
26
+ }
27
+
28
+ const accountIds = listTeamilyAccountIds(cfg);
29
+ const targetAccountId = accountId || accountIds[0];
30
+
31
+ if (!targetAccountId) {
32
+ throw new Error("No Teamily account configured");
33
+ }
34
+
35
+ const account = config.accounts?.[targetAccountId];
36
+ if (!account) {
37
+ throw new Error(`Teamily account ${targetAccountId} not found`);
38
+ }
39
+
40
+ return {
41
+ accountId: targetAccountId,
42
+ enabled: true,
43
+ platformUrl: config.server.platformUrl,
44
+ apiURL: config.server.apiURL,
45
+ wsURL: config.server.wsURL,
46
+ userID: account.userID,
47
+ token: account.token,
48
+ nickname: account.nickname,
49
+ faceURL: account.faceURL,
50
+ };
51
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,411 @@
1
+ import {
2
+ applyAccountNameToChannelSection,
3
+ buildChannelConfigSchema,
4
+ DEFAULT_ACCOUNT_ID,
5
+ deleteAccountFromConfigSection,
6
+ formatPairingApproveHint,
7
+ normalizeAccountId,
8
+ PAIRING_APPROVED_MESSAGE,
9
+ setAccountEnabledInConfigSection,
10
+ type ChannelPlugin,
11
+ type ChannelOutboundContext,
12
+ type ChannelOutboundAdapter,
13
+ type ChannelStatusAdapter,
14
+ type ChannelStatusIssue,
15
+ } from "openclaw/plugin-sdk";
16
+ import { TeamilyConfigSchema } from "./config-schema.js";
17
+ import {
18
+ listTeamilyAccountIds,
19
+ resolveDefaultTeamilyAccountId,
20
+ resolveTeamilyAccount,
21
+ type ResolvedTeamilyAccount,
22
+ } from "./accounts.js";
23
+ import { probeTeamily } from "./probe.js";
24
+ import { sendMessageTeamily, sendMediaTeamily } from "./send.js";
25
+ import { startTeamilyMonitoring, stopTeamilyMonitoring } from "./monitor.js";
26
+ import { normalizeTeamilyTarget, normalizeTeamilyAllowEntry } from "./normalize.js";
27
+ import { SESSION_TYPES } from "./types.js";
28
+ import { getTeamilyRuntime } from "./runtime.js";
29
+ import type { CoreConfig } from "./config-schema.js";
30
+
31
+ const meta = {
32
+ id: "teamily",
33
+ label: "Teamily",
34
+ selectionLabel: "Teamily (self-hosted)",
35
+ docsPath: "/channels/teamily",
36
+ docsLabel: "teamily",
37
+ blurb: "Team instant messaging server",
38
+ order: 75,
39
+ quickstartAllowFrom: true,
40
+ };
41
+
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
+ };
50
+
51
+ export const teamilyPlugin: ChannelPlugin<ResolvedTeamilyAccount> = {
52
+ id: "teamily",
53
+ meta,
54
+ capabilities,
55
+ onboarding: {
56
+ promptAccountId,
57
+ resolveAccountId,
58
+ applyAccountName: ({ cfg, accountId, name }) =>
59
+ applyAccountNameToChannelSection({
60
+ cfg: cfg as CoreConfig,
61
+ sectionKey: "teamily",
62
+ accountId,
63
+ name,
64
+ allowTopLevel: true,
65
+ }),
66
+ applyAccountConfig: ({ cfg, accountId, input }) =>
67
+ applyTeamilyAccountConfig({ cfg: cfg as CoreConfig, accountId, input }),
68
+ resolveBindingAccountId: ({ cfg }) => resolveDefaultTeamilyAccountId(cfg as CoreConfig),
69
+ },
70
+ pairing: {
71
+ idLabel: "teamilyUserId",
72
+ normalizeAllowEntry: (entry) => {
73
+ try {
74
+ return normalizeTeamilyAllowEntry(entry);
75
+ } catch {
76
+ return entry;
77
+ }
78
+ },
79
+ notifyApproval: async ({ id, cfg }) => {
80
+ try {
81
+ const accountId = resolveDefaultTeamilyAccountId(cfg as CoreConfig);
82
+ const account = resolveTeamilyAccount(cfg as CoreConfig, accountId);
83
+ const target = normalizeTeamilyTarget(id);
84
+ await sendMessageTeamily({
85
+ account,
86
+ target,
87
+ text: PAIRING_APPROVED_MESSAGE,
88
+ });
89
+ } catch {
90
+ // Silently fail on notification
91
+ }
92
+ },
93
+ },
94
+ configSchema: buildChannelConfigSchema(TeamilyConfigSchema),
95
+ config: {
96
+ listAccountIds: (cfg) => listTeamilyAccountIds(cfg as CoreConfig),
97
+ resolveAccount: (cfg, accountId) =>
98
+ resolveTeamilyAccount(cfg as CoreConfig, accountId),
99
+ defaultAccountId: (cfg) => resolveDefaultTeamilyAccountId(cfg as CoreConfig),
100
+ setAccountEnabled: ({ cfg, accountId, enabled }) =>
101
+ setAccountEnabledInConfigSection({
102
+ cfg: cfg as CoreConfig,
103
+ sectionKey: "teamily",
104
+ accountId,
105
+ enabled,
106
+ allowTopLevel: true,
107
+ }),
108
+ deleteAccount: ({ cfg, accountId }) =>
109
+ deleteAccountFromConfigSection({
110
+ cfg: cfg as CoreConfig,
111
+ sectionKey: "teamily",
112
+ accountId,
113
+ clearBaseFields: [
114
+ "name",
115
+ "userID",
116
+ "token",
117
+ "nickname",
118
+ "faceURL",
119
+ ],
120
+ }),
121
+ isConfigured: (account) => !!account.token,
122
+ describeAccount: (account) => ({
123
+ accountId: account.accountId,
124
+ name: account.nickname || account.userID,
125
+ enabled: account.enabled,
126
+ configured: !!account.token,
127
+ }),
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;
138
+ },
139
+ },
140
+ outbound: {
141
+ sendText: async (ctx: ChannelOutboundContext) => {
142
+ const { to, text, accountId } = ctx;
143
+ const account = resolveTeamilyAccount(ctx.cfg, accountId);
144
+ const target = normalizeTeamilyTarget(to);
145
+
146
+ const result = await sendMessageTeamily({
147
+ account,
148
+ target,
149
+ text,
150
+ replyToId: ctx.replyToId || undefined,
151
+ });
152
+
153
+ if (!result.success) {
154
+ throw new Error(result.error || "Failed to send message");
155
+ }
156
+
157
+ return { messageId: result.messageId };
158
+ },
159
+ sendMedia: async (ctx: ChannelOutboundContext) => {
160
+ const { to, mediaUrl, text, accountId } = ctx;
161
+ const account = resolveTeamilyAccount(ctx.cfg, accountId);
162
+ const target = normalizeTeamilyTarget(to);
163
+
164
+ // Determine media type from URL or assume image
165
+ let mediaType: "image" | "video" | "audio" | "file" = "image";
166
+ const urlLower = mediaUrl.toLowerCase();
167
+ if (urlLower.endsWith(".mp4") || urlLower.endsWith(".mov") || urlLower.endsWith(".webm")) {
168
+ mediaType = "video";
169
+ } else if (urlLower.endsWith(".mp3") || urlLower.endsWith(".m4a") || urlLower.endsWith(".wav")) {
170
+ mediaType = "audio";
171
+ } else if (
172
+ urlLower.endsWith(".pdf") ||
173
+ urlLower.endsWith(".doc") ||
174
+ urlLower.endsWith(".docx") ||
175
+ urlLower.endsWith(".zip")
176
+ ) {
177
+ mediaType = "file";
178
+ }
179
+
180
+ const result = await sendMediaTeamily({
181
+ account,
182
+ target,
183
+ mediaUrl,
184
+ mediaType,
185
+ caption: text,
186
+ });
187
+
188
+ if (!result.success) {
189
+ throw new Error(result.error || "Failed to send media");
190
+ }
191
+
192
+ return { messageId: result.messageId };
193
+ },
194
+ resolveTarget: (raw) => {
195
+ return normalizeTeamilyTarget(raw).id;
196
+ },
197
+ },
198
+ status: {
199
+ probeAccount: async (cfg, accountId) => {
200
+ const account = resolveTeamilyAccount(cfg as CoreConfig, accountId);
201
+ const result = await probeTeamily(account);
202
+
203
+ if (!result.connected) {
204
+ return {
205
+ connected: false,
206
+ error: result.error || "Failed to connect to Teamily server",
207
+ };
208
+ }
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
+ };
220
+ },
221
+ collectStatusIssues: async (cfg, accountId) => {
222
+ 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
+ });
254
+ }
255
+
256
+ return issues;
257
+ },
258
+ },
259
+ gateway: {
260
+ startAccount: async (ctx) => {
261
+ const { cfg, accountId, account, log } = ctx;
262
+
263
+ if (!account.token) {
264
+ log?.warn?.(`Teamily account ${accountId} not configured (missing token)`);
265
+ return { stop: () => {} };
266
+ }
267
+
268
+ log?.info?.(`Starting Teamily channel (account: ${accountId})`);
269
+
270
+ const stopFn = startTeamilyMonitoring(account, async (message) => {
271
+ const rt = getTeamilyRuntime();
272
+ const currentCfg = rt.config.loadConfig();
273
+
274
+ const isGroup = message.sessionType === SESSION_TYPES.GROUP;
275
+ const from = message.sendID;
276
+ const text = message.content?.text || "";
277
+ const sessionKey = isGroup ? `teamily:group:${message.recvID}` : `teamily:${from}`;
278
+
279
+ let mediaUrl: string | undefined;
280
+ if (message.content?.picture?.sourcePicture?.url) {
281
+ mediaUrl = message.content.picture.sourcePicture.url;
282
+ } else if (message.content?.video?.videoUrl) {
283
+ mediaUrl = message.content.video.videoUrl;
284
+ } else if (message.content?.audio?.sourceUrl) {
285
+ mediaUrl = message.content.audio.sourceUrl;
286
+ }
287
+
288
+ const msgCtx = {
289
+ Body: text,
290
+ From: from,
291
+ To: account.userID,
292
+ SessionKey: sessionKey,
293
+ AccountId: accountId,
294
+ OriginatingChannel: "teamily" as any,
295
+ OriginatingTo: from,
296
+ ChatType: isGroup ? "group" : "direct",
297
+ MediaUrl: mediaUrl,
298
+ };
299
+
300
+ await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
301
+ ctx: msgCtx,
302
+ cfg: currentCfg,
303
+ dispatcherOptions: {
304
+ deliver: async (payload: { text?: string; body?: string }) => {
305
+ const replyText = payload?.text ?? payload?.body;
306
+ if (replyText) {
307
+ const target = normalizeTeamilyTarget(from);
308
+ await sendMessageTeamily({ account, target, text: replyText });
309
+ }
310
+ },
311
+ onReplyStart: () => {
312
+ log?.info?.(`Agent reply started for ${from}`);
313
+ },
314
+ },
315
+ });
316
+ });
317
+
318
+ // Respect abort signal from the gateway framework
319
+ ctx.abortSignal.addEventListener("abort", () => {
320
+ stopFn();
321
+ stopTeamilyMonitoring(accountId);
322
+ });
323
+
324
+ // Return a promise that never resolves (monitor runs indefinitely)
325
+ return new Promise<void>(() => {});
326
+ },
327
+ },
328
+ };
329
+
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
+ function applyTeamilyAccountConfig(params: {
357
+ cfg: CoreConfig;
358
+ accountId: string;
359
+ input: Record<string, unknown>;
360
+ }): CoreConfig {
361
+ const { cfg, accountId, input } = params;
362
+ const existing = cfg.channels?.teamily || { enabled: false, server: {}, accounts: {} };
363
+
364
+ 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
+ }
377
+
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
+ }
389
+
390
+ return {
391
+ ...cfg,
392
+ channels: {
393
+ ...cfg.channels,
394
+ teamily: {
395
+ ...existing,
396
+ enabled: true,
397
+ server: {
398
+ ...existing.server,
399
+ ...serverUpdate,
400
+ },
401
+ accounts: {
402
+ ...existing.accounts,
403
+ [accountId]: {
404
+ ...(existing.accounts?.[accountId] || {}),
405
+ ...accountUpdate,
406
+ },
407
+ },
408
+ },
409
+ },
410
+ };
411
+ }
@@ -0,0 +1,67 @@
1
+ import { z } from "zod";
2
+ import type { ChannelConfigSchema } from "openclaw/plugin-sdk";
3
+ import { buildChannelConfigSchema } from "openclaw/plugin-sdk";
4
+ import type { TeamilyConfig } from "./types.js";
5
+
6
+ // Server configuration schema
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"),
23
+ });
24
+
25
+ // User account configuration schema
26
+ 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"),
44
+ });
45
+
46
+ // Main Teamily configuration schema
47
+ export const TeamilyConfigSchema = z.object({
48
+ enabled: z
49
+ .boolean()
50
+ .default(true)
51
+ .describe("Enable Teamily channel"),
52
+ server: TeamilyServerConfigSchema.describe("Teamily server configuration"),
53
+ accounts: z
54
+ .record(z.string(), TeamilyUserAccountSchema)
55
+ .default({})
56
+ .describe("Teamily bot accounts"),
57
+ });
58
+
59
+ export const TeamilyChannelConfigSchema = buildChannelConfigSchema(
60
+ TeamilyConfigSchema
61
+ ) as ChannelConfigSchema;
62
+
63
+ export type CoreConfig = {
64
+ channels?: {
65
+ teamily?: TeamilyConfig;
66
+ };
67
+ };