@cored-im/openclaw-plugin 0.1.0

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.
@@ -0,0 +1,176 @@
1
+ // Copyright (c) 2026 Cored Limited
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Outbound message delivery — send text/typing/read to Cored chats.
6
+ *
7
+ * Pipeline: normalize target -> validate account/client -> send -> map errors
8
+ *
9
+ * Text-only for now; media/card/file delivery is follow-up (task 08).
10
+ */
11
+
12
+ import {
13
+ getClient,
14
+ isAuthError,
15
+ MessageType_TEXT,
16
+ type ManagedClient,
17
+ type SendMessageReq,
18
+ } from "../core/cored-client.js";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Send text
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export interface SendTextResult {
25
+ ok: boolean;
26
+ messageId?: string;
27
+ error?: Error;
28
+ provider?: string;
29
+ }
30
+
31
+ /**
32
+ * Send a text message to a Cored chat.
33
+ *
34
+ * On auth error (code 40000006), forces a token refresh via preheat() and
35
+ * retries once. This handles the SDK's token-refresh edge case without
36
+ * requiring a gateway restart.
37
+ */
38
+ export async function sendText(
39
+ chatId: string,
40
+ text: string,
41
+ accountId?: string,
42
+ replyMessageId?: string,
43
+ logWarn?: (msg: string) => void,
44
+ ): Promise<SendTextResult> {
45
+ const managed = getClient(accountId);
46
+ if (!managed) {
47
+ return {
48
+ ok: false,
49
+ error: new Error(
50
+ `[cored] no connected client for account=${accountId ?? "default"}`,
51
+ ),
52
+ };
53
+ }
54
+
55
+ const req: SendMessageReq = {
56
+ chat_id: chatId,
57
+ message_type: MessageType_TEXT,
58
+ message_content: {
59
+ text: { content: text },
60
+ },
61
+ reply_message_id: replyMessageId,
62
+ };
63
+
64
+ try {
65
+ const resp = await managed.client.Im.v1.Message.sendMessage(req);
66
+ return { ok: true, messageId: resp.message_id, provider: "cored" };
67
+ } catch (err) {
68
+ if (!isAuthError(err)) {
69
+ return {
70
+ ok: false,
71
+ error: new Error(
72
+ `[cored] send failed for chat=${chatId}: ${err instanceof Error ? err.message : String(err)}`,
73
+ ),
74
+ };
75
+ }
76
+
77
+ // Auth token expired despite SDK auto-refresh — force refresh and retry once.
78
+ // This avoids requiring a gateway restart when the SDK's background token
79
+ // refresh hits an edge case (time sync drift, swallowed fetch failure, etc.).
80
+ logWarn?.(
81
+ `[cored] auth error on send (chat=${chatId}, account=${accountId ?? "default"}) — refreshing token and retrying`,
82
+ );
83
+
84
+ try {
85
+ await managed.client.preheat();
86
+ const resp = await managed.client.Im.v1.Message.sendMessage(req);
87
+ logWarn?.(
88
+ `[cored] auth retry succeeded (chat=${chatId}, account=${accountId ?? "default"})`,
89
+ );
90
+ return { ok: true, messageId: resp.message_id, provider: "cored" };
91
+ } catch (retryErr) {
92
+ return {
93
+ ok: false,
94
+ error: new Error(
95
+ `[cored] send failed after auth retry for chat=${chatId}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
96
+ ),
97
+ };
98
+ }
99
+ }
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Typing indicator
104
+ // ---------------------------------------------------------------------------
105
+
106
+ /**
107
+ * Set typing indicator in a chat. Cored typing lasts ~5s.
108
+ */
109
+ export async function setTyping(
110
+ chatId: string,
111
+ accountId?: string,
112
+ ): Promise<void> {
113
+ const managed = getClient(accountId);
114
+ if (!managed) return;
115
+ try {
116
+ await managed.client.Im.v1.Chat.createTyping({ chat_id: chatId });
117
+ } catch {
118
+ // Typing is best-effort — don't fail the message flow
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Clear typing indicator.
124
+ */
125
+ export async function clearTyping(
126
+ chatId: string,
127
+ accountId?: string,
128
+ ): Promise<void> {
129
+ const managed = getClient(accountId);
130
+ if (!managed) return;
131
+ try {
132
+ await managed.client.Im.v1.Chat.deleteTyping({ chat_id: chatId });
133
+ } catch {
134
+ // Best-effort
135
+ }
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Read receipt
140
+ // ---------------------------------------------------------------------------
141
+
142
+ /**
143
+ * Mark a message as read.
144
+ */
145
+ export async function readMessage(
146
+ messageId: string,
147
+ accountId?: string,
148
+ ): Promise<void> {
149
+ const managed = getClient(accountId);
150
+ if (!managed) return;
151
+ try {
152
+ await managed.client.Im.v1.Message.readMessage({ message_id: messageId });
153
+ } catch {
154
+ // Best-effort
155
+ }
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Delivery callback for inbound dispatch
160
+ // ---------------------------------------------------------------------------
161
+
162
+ /**
163
+ * Create a deliver function scoped to an account, suitable for passing
164
+ * to processInboundMessage's InboundDispatchOptions.
165
+ */
166
+ export function makeDeliver(
167
+ accountId?: string,
168
+ logWarn?: (msg: string) => void,
169
+ ): (chatId: string, text: string) => Promise<void> {
170
+ return async (chatId: string, text: string) => {
171
+ const result = await sendText(chatId, text, accountId, undefined, logWarn);
172
+ if (!result.ok) {
173
+ throw result.error ?? new Error("[cored] send failed");
174
+ }
175
+ };
176
+ }
@@ -0,0 +1,91 @@
1
+ // Copyright (c) 2026 Cored Limited
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { describe, it, expect } from "vitest";
5
+ import { parseTarget } from "./targets.js";
6
+
7
+ describe("parseTarget", () => {
8
+ // --- Happy paths ---
9
+
10
+ it('parses "user:<id>"', () => {
11
+ expect(parseTarget("user:abc123")).toEqual({ kind: "user", id: "abc123" });
12
+ });
13
+
14
+ it('parses "chat:<id>"', () => {
15
+ expect(parseTarget("chat:oc_xyz")).toEqual({ kind: "chat", id: "oc_xyz" });
16
+ });
17
+
18
+ it('strips "cored:" prefix and parses "chat:<id>"', () => {
19
+ expect(parseTarget("cored:chat:oc_xyz")).toEqual({
20
+ kind: "chat",
21
+ id: "oc_xyz",
22
+ });
23
+ });
24
+
25
+ it('strips "cored:" prefix and parses "user:<id>"', () => {
26
+ expect(parseTarget("cored:user:abc")).toEqual({
27
+ kind: "user",
28
+ id: "abc",
29
+ });
30
+ });
31
+
32
+ it("strips cored: prefix case-insensitively", () => {
33
+ expect(parseTarget("CORED:chat:oc_1")).toEqual({
34
+ kind: "chat",
35
+ id: "oc_1",
36
+ });
37
+ expect(parseTarget("Cored:user:u1")).toEqual({ kind: "user", id: "u1" });
38
+ });
39
+
40
+ it("defaults bare ID to chat", () => {
41
+ expect(parseTarget("oc_abc123")).toEqual({
42
+ kind: "chat",
43
+ id: "oc_abc123",
44
+ });
45
+ });
46
+
47
+ it("defaults bare numeric ID to chat", () => {
48
+ expect(parseTarget("83870344313569283")).toEqual({
49
+ kind: "chat",
50
+ id: "83870344313569283",
51
+ });
52
+ });
53
+
54
+ it("trims whitespace", () => {
55
+ expect(parseTarget(" chat:oc_1 ")).toEqual({
56
+ kind: "chat",
57
+ id: "oc_1",
58
+ });
59
+ expect(parseTarget(" user:abc ")).toEqual({ kind: "user", id: "abc" });
60
+ });
61
+
62
+ // --- Null/invalid cases ---
63
+
64
+ it("returns null for undefined", () => {
65
+ expect(parseTarget(undefined)).toBeNull();
66
+ });
67
+
68
+ it("returns null for empty string", () => {
69
+ expect(parseTarget("")).toBeNull();
70
+ });
71
+
72
+ it("returns null for whitespace-only", () => {
73
+ expect(parseTarget(" ")).toBeNull();
74
+ });
75
+
76
+ it('returns null for "user:" with no ID', () => {
77
+ expect(parseTarget("user:")).toBeNull();
78
+ });
79
+
80
+ it('returns null for "chat:" with no ID', () => {
81
+ expect(parseTarget("chat:")).toBeNull();
82
+ });
83
+
84
+ it('returns null for "user: " (whitespace-only ID)', () => {
85
+ expect(parseTarget("user: ")).toBeNull();
86
+ });
87
+
88
+ it('returns null for "cored:" with nothing after', () => {
89
+ expect(parseTarget("cored:")).toBeNull();
90
+ });
91
+ });
package/src/targets.ts ADDED
@@ -0,0 +1,41 @@
1
+ // Copyright (c) 2026 Cored Limited
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Target parsing & validation for the --to argument.
6
+ *
7
+ * Accepted formats:
8
+ * "chat:oc_abc123" -> { kind: "chat", id: "oc_abc123" }
9
+ * "user:83870344313569283" -> { kind: "user", id: "83870344313569283" }
10
+ * "cored:chat:oc_abc123" -> { kind: "chat", id: "oc_abc123" } (prefix stripped)
11
+ * "oc_abc123" -> { kind: "chat", id: "oc_abc123" } (bare ID defaults to chat)
12
+ *
13
+ * Returns null for empty, whitespace-only, or malformed targets (e.g. "user:" with no ID).
14
+ */
15
+
16
+ import type { ParsedTarget } from "./types.js";
17
+
18
+ /**
19
+ * Parse a raw --to string into a structured target.
20
+ * Returns null if the input is missing, empty, or has no usable ID.
21
+ */
22
+ export function parseTarget(to?: string): ParsedTarget | null {
23
+ const raw = String(to ?? "").trim();
24
+ if (!raw) return null;
25
+
26
+ // Strip optional "cored:" channel prefix (case-insensitive)
27
+ const stripped = raw.replace(/^cored:/i, "");
28
+
29
+ if (stripped.startsWith("user:")) {
30
+ const id = stripped.slice("user:".length).trim();
31
+ return id ? { kind: "user", id } : null;
32
+ }
33
+
34
+ if (stripped.startsWith("chat:")) {
35
+ const id = stripped.slice("chat:".length).trim();
36
+ return id ? { kind: "chat", id } : null;
37
+ }
38
+
39
+ // Bare ID — default to chat (Cored SDK uses ChatId for sending)
40
+ return stripped ? { kind: "chat", id: stripped } : null;
41
+ }
@@ -0,0 +1,10 @@
1
+ // Copyright (c) 2026 Cored Limited
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { describe, it, expect } from "vitest";
5
+
6
+ describe("project setup", () => {
7
+ it("builds and runs tests", () => {
8
+ expect(true).toBe(true);
9
+ });
10
+ });
package/src/types.ts ADDED
@@ -0,0 +1,115 @@
1
+ // Copyright (c) 2026 Cored Limited
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // --- Plugin Config Types ---
5
+
6
+ export interface CoredAccountConfig {
7
+ accountId: string;
8
+ enabled: boolean;
9
+ appId: string;
10
+ appSecret: string;
11
+ backendUrl: string;
12
+ enableEncryption: boolean;
13
+ requestTimeout: number;
14
+ requireMention: boolean;
15
+ botUserId?: string;
16
+ inboundWhitelist: string[];
17
+ }
18
+
19
+ export interface CoredRawAccountConfig {
20
+ appId?: string;
21
+ appSecret?: string;
22
+ backendUrl?: string;
23
+ enableEncryption?: boolean;
24
+ requestTimeout?: number;
25
+ requireMention?: boolean;
26
+ enabled?: boolean;
27
+ botUserId?: string;
28
+ inboundWhitelist?: string[];
29
+ }
30
+
31
+ export interface CoredChannelConfig {
32
+ appId?: string;
33
+ appSecret?: string;
34
+ backendUrl?: string;
35
+ enableEncryption?: boolean;
36
+ requestTimeout?: number;
37
+ requireMention?: boolean;
38
+ enabled?: boolean;
39
+ botUserId?: string;
40
+ inboundWhitelist?: string[];
41
+ accounts?: Record<string, CoredRawAccountConfig>;
42
+ }
43
+
44
+ // --- Cored Event Types ---
45
+
46
+ export interface CoredUserId {
47
+ userId: string;
48
+ unionUserId?: string;
49
+ openUserId?: string;
50
+ }
51
+
52
+ export interface CoredMessage {
53
+ messageId: string;
54
+ messageType: string;
55
+ messageContent: unknown;
56
+ chatId: string;
57
+ chatType: string;
58
+ sender: CoredUserId;
59
+ createdAt: number;
60
+ mentionUserList?: CoredUserId[];
61
+ }
62
+
63
+ export interface CoredMessageEvent {
64
+ message: CoredMessage;
65
+ }
66
+
67
+ // --- Target Types ---
68
+
69
+ export interface ParsedTarget {
70
+ kind: "user" | "chat";
71
+ id: string;
72
+ }
73
+
74
+ // --- Connection Types ---
75
+
76
+ export type ConnectionState =
77
+ | "disconnected"
78
+ | "connecting"
79
+ | "connected"
80
+ | "disconnecting";
81
+
82
+ // --- OpenClaw Plugin API (minimal type surface) ---
83
+
84
+ export interface PluginApi {
85
+ registerChannel(opts: { plugin: unknown }): void;
86
+ registerService(opts: {
87
+ id: string;
88
+ start: () => Promise<void>;
89
+ stop: () => Promise<void>;
90
+ }): void;
91
+ config: { channels?: { cored?: CoredChannelConfig } } & Record<
92
+ string,
93
+ unknown
94
+ >;
95
+ runtime: {
96
+ channel: {
97
+ reply: {
98
+ dispatchReplyWithBufferedBlockDispatcher: (opts: unknown) => Promise<void>;
99
+ };
100
+ session: {
101
+ recordInboundSession: (opts: unknown) => Promise<void>;
102
+ resolveStorePath?: (store: unknown, opts: unknown) => string;
103
+ };
104
+ routing: {
105
+ resolveAgentRoute: (opts: unknown) => unknown;
106
+ };
107
+ };
108
+ };
109
+ logger?: {
110
+ info: (msg: string) => void;
111
+ warn: (msg: string) => void;
112
+ error: (msg: string) => void;
113
+ debug: (msg: string) => void;
114
+ };
115
+ }
@@ -0,0 +1,23 @@
1
+ // Copyright (c) 2026 Cored Limited
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Re-export types from @cored-im/sdk used across the plugin.
6
+ *
7
+ * The published SDK ships its own type declarations. This file exists
8
+ * only to keep the rest of the plugin importing from a single local
9
+ * barrel, making future SDK swaps cheaper.
10
+ */
11
+ export type {
12
+ CoredClient,
13
+ CoredClientOptions,
14
+ } from "@cored-im/sdk";
15
+
16
+ export {
17
+ LoggerLevel,
18
+ } from "@cored-im/sdk";
19
+
20
+ export type {
21
+ Logger,
22
+ EventHeader,
23
+ } from "@cored-im/sdk";