@wzfukui/ani 2026.3.28

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,183 @@
1
+ import type { ChannelOutboundAdapter } from "./sdk-compat.js";
2
+
3
+ import { getAniRuntime } from "./runtime.js";
4
+ import { sendAniMessage, uploadAniFile, toggleAniReaction } from "./monitor/send.js";
5
+ import type { AniInteraction, AniAttachment } from "./monitor/send.js";
6
+ import { resolveAniCredentials } from "./utils.js";
7
+
8
+ /** Parse conversation ID from target string like "ani:conv:123" or "123". */
9
+ export function parseConversationId(to: string): number {
10
+ const cleaned = to
11
+ .replace(/^ani:/i, "")
12
+ .replace(/^conv:/i, "")
13
+ .replace(/^channel:/i, "")
14
+ .trim();
15
+ if (!/^[1-9]\d*$/.test(cleaned)) {
16
+ throw new Error(`ANI outbound: invalid conversation target "${to}"`);
17
+ }
18
+ return Number.parseInt(cleaned, 10);
19
+ }
20
+
21
+ /**
22
+ * Send a text message with optional mentions and interaction card.
23
+ * The `mentions` and `interaction` fields map directly to the ANI backend's
24
+ * message send API (POST /api/v1/messages/send).
25
+ */
26
+ export async function sendAniTextWithExtras(opts: {
27
+ to: string;
28
+ text: string;
29
+ mentions?: number[];
30
+ interaction?: AniInteraction;
31
+ }): Promise<{ channel: string; messageId: string; roomId: string }> {
32
+ const { serverUrl, apiKey } = resolveAniCredentials();
33
+ const conversationId = parseConversationId(opts.to);
34
+ const result = await sendAniMessage({
35
+ serverUrl,
36
+ apiKey,
37
+ conversationId,
38
+ text: opts.text,
39
+ mentions: opts.mentions,
40
+ interaction: opts.interaction,
41
+ });
42
+ return {
43
+ channel: "ani",
44
+ messageId: String(result.messageId),
45
+ roomId: String(conversationId),
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Send an ack-reaction (emoji) on a specific ANI message.
51
+ * Uses the toggle endpoint: POST /api/v1/messages/:id/reactions
52
+ */
53
+ export async function sendAniAckReaction(messageId: number, emoji: string): Promise<void> {
54
+ const { serverUrl, apiKey } = resolveAniCredentials();
55
+ await toggleAniReaction({ serverUrl, apiKey, messageId, emoji });
56
+ }
57
+
58
+ export const aniOutbound: ChannelOutboundAdapter = {
59
+ deliveryMode: "direct",
60
+ chunker: (text, limit) => getAniRuntime().channel.text.chunkMarkdownText(text, limit),
61
+ chunkerMode: "markdown",
62
+ textChunkLimit: 4000,
63
+
64
+ sendText: async ({ to, text, mentions }) => {
65
+ // Pass through mentions if the OpenClaw context provides them.
66
+ // OpenClaw may pass mentions as an array of string IDs; convert to numbers.
67
+ const mentionIds = Array.isArray(mentions)
68
+ ? mentions.map((m) => typeof m === "number" ? m : Number.parseInt(String(m), 10)).filter((n) => !Number.isNaN(n) && n > 0)
69
+ : undefined;
70
+
71
+ const { serverUrl, apiKey } = resolveAniCredentials();
72
+ const conversationId = parseConversationId(to);
73
+ const result = await sendAniMessage({
74
+ serverUrl,
75
+ apiKey,
76
+ conversationId,
77
+ text,
78
+ mentions: mentionIds,
79
+ });
80
+ return {
81
+ channel: "ani",
82
+ messageId: String(result.messageId),
83
+ roomId: String(conversationId),
84
+ };
85
+ },
86
+
87
+ sendMedia: async ({ to, text, mediaUrl }) => {
88
+ const { serverUrl, apiKey } = resolveAniCredentials();
89
+ const conversationId = parseConversationId(to);
90
+
91
+ let attachments: AniAttachment[] | undefined;
92
+
93
+ if (mediaUrl) {
94
+ try {
95
+ // Download media from the provided URL
96
+ const mediaRes = await fetch(mediaUrl, { signal: AbortSignal.timeout(30_000) });
97
+ if (!mediaRes.ok) {
98
+ throw new Error(`Failed to download media (${mediaRes.status})`);
99
+ }
100
+
101
+ // Reject files larger than 32MB before reading the body
102
+ const contentLength = Number(mediaRes.headers.get("content-length") || 0);
103
+ if (contentLength > 32 * 1024 * 1024) {
104
+ await mediaRes.body?.cancel();
105
+ throw new Error(`Media too large: ${contentLength} bytes (max 32MB)`);
106
+ }
107
+
108
+ const contentType = mediaRes.headers.get("content-type") ?? "application/octet-stream";
109
+ const buffer = new Uint8Array(await mediaRes.arrayBuffer());
110
+
111
+ // Derive filename from URL path or use a fallback
112
+ let filename = "file";
113
+ try {
114
+ const urlPath = new URL(mediaUrl).pathname;
115
+ const lastSegment = urlPath.split("/").pop();
116
+ if (lastSegment && lastSegment.includes(".")) {
117
+ filename = lastSegment;
118
+ }
119
+ } catch {
120
+ // URL parsing failed; keep default filename
121
+ }
122
+
123
+ // Upload to ANI backend
124
+ const uploaded = await uploadAniFile({
125
+ serverUrl,
126
+ apiKey,
127
+ buffer,
128
+ filename,
129
+ });
130
+
131
+ // Determine attachment type from MIME
132
+ let attachType = "file";
133
+ if (contentType.startsWith("image/")) attachType = "image";
134
+ else if (contentType.startsWith("audio/")) attachType = "audio";
135
+ else if (contentType.startsWith("video/")) attachType = "video";
136
+
137
+ attachments = [
138
+ {
139
+ type: attachType,
140
+ url: uploaded.url,
141
+ filename: uploaded.filename,
142
+ mime_type: contentType,
143
+ size: uploaded.size,
144
+ },
145
+ ];
146
+ } catch (err) {
147
+ // If media download/upload fails, fall back to sending text with a link
148
+ getAniRuntime().logging?.verbose(`ani: sendMedia failed, falling back to text: ${String(err)}`);
149
+ const fallbackText = text
150
+ ? `${text}\n\n[Media link: ${mediaUrl}]`
151
+ : `[Media link: ${mediaUrl}]`;
152
+ const result = await sendAniMessage({
153
+ serverUrl,
154
+ apiKey,
155
+ conversationId,
156
+ text: fallbackText,
157
+ });
158
+ return {
159
+ channel: "ani",
160
+ messageId: String(result.messageId),
161
+ roomId: String(conversationId),
162
+ };
163
+ }
164
+ }
165
+
166
+ // Determine content type from first attachment
167
+ const contentType = attachments?.[0]?.type;
168
+
169
+ const result = await sendAniMessage({
170
+ serverUrl,
171
+ apiKey,
172
+ conversationId,
173
+ text: text ?? "",
174
+ attachments,
175
+ contentType,
176
+ });
177
+ return {
178
+ channel: "ani",
179
+ messageId: String(result.messageId),
180
+ roomId: String(conversationId),
181
+ };
182
+ },
183
+ };
package/src/runtime.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { PluginRuntime } from "./sdk-compat.js";
2
+
3
+ let runtime: PluginRuntime | null = null;
4
+
5
+ export function setAniRuntime(next: PluginRuntime) {
6
+ runtime = next;
7
+ }
8
+
9
+ export function getAniRuntime(): PluginRuntime {
10
+ if (!runtime) {
11
+ throw new Error("ANI runtime not initialized");
12
+ }
13
+ return runtime;
14
+ }
@@ -0,0 +1,59 @@
1
+ import { createRequire } from "node:module";
2
+ import path from "node:path";
3
+
4
+ import type {
5
+ ChannelAgentTool,
6
+ ChannelOutboundAdapter,
7
+ ChannelPlugin,
8
+ OpenClawPluginApi,
9
+ PluginRuntime,
10
+ RuntimeEnv,
11
+ } from "openclaw/plugin-sdk";
12
+
13
+ const require = createRequire(import.meta.url);
14
+
15
+ function loadSdkRuntimeModule<T>(relativeFile: string, fallback = "openclaw/plugin-sdk"): T {
16
+ const rootEntry = require.resolve("openclaw/plugin-sdk");
17
+ const candidate = path.join(path.dirname(rootEntry), relativeFile);
18
+ try {
19
+ return require(candidate) as T;
20
+ } catch {
21
+ return require(fallback) as T;
22
+ }
23
+ }
24
+
25
+ const coreSdk = loadSdkRuntimeModule<{
26
+ DEFAULT_ACCOUNT_ID: string;
27
+ normalizeAccountId: (accountId?: string) => string;
28
+ setAccountEnabledInConfigSection: (...args: any[]) => any;
29
+ deleteAccountFromConfigSection: (...args: any[]) => any;
30
+ applyAccountNameToChannelSection: (...args: any[]) => any;
31
+ buildChannelConfigSchema: (...args: any[]) => any;
32
+ emptyPluginConfigSchema: () => unknown;
33
+ }>("core.js");
34
+
35
+ const channelRuntimeSdk = loadSdkRuntimeModule<{
36
+ createReplyPrefixContext: (...args: any[]) => any;
37
+ createTypingCallbacks: (...args: any[]) => any;
38
+ }>("channel-runtime.js");
39
+
40
+ export const {
41
+ DEFAULT_ACCOUNT_ID,
42
+ normalizeAccountId,
43
+ setAccountEnabledInConfigSection,
44
+ deleteAccountFromConfigSection,
45
+ applyAccountNameToChannelSection,
46
+ buildChannelConfigSchema,
47
+ emptyPluginConfigSchema,
48
+ } = coreSdk;
49
+
50
+ export const { createReplyPrefixContext, createTypingCallbacks } = channelRuntimeSdk;
51
+
52
+ export type {
53
+ ChannelAgentTool,
54
+ ChannelOutboundAdapter,
55
+ ChannelPlugin,
56
+ OpenClawPluginApi,
57
+ PluginRuntime,
58
+ RuntimeEnv,
59
+ };