openclaw-groupme 0.0.1

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,153 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { CoreConfig } from "./types.js";
3
+ import {
4
+ sendGroupMeMedia,
5
+ sendGroupMeMessage,
6
+ sendGroupMeText,
7
+ uploadGroupMeImage,
8
+ } from "./send.js";
9
+
10
+ describe("sendGroupMeMessage", () => {
11
+ it("sends text message", async () => {
12
+ const fetchMock = vi.fn(async () => new Response("", { status: 201, statusText: "Created" }));
13
+
14
+ await sendGroupMeMessage({
15
+ botId: "bot-1",
16
+ text: "hello",
17
+ fetchFn: fetchMock as unknown as typeof fetch,
18
+ });
19
+
20
+ expect(fetchMock).toHaveBeenCalledTimes(1);
21
+ const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit];
22
+ expect(url).toBe("https://api.groupme.com/v3/bots/post");
23
+ const body = JSON.parse(String(options.body));
24
+ expect(body).toEqual({ bot_id: "bot-1", text: "hello" });
25
+ });
26
+
27
+ it("sends message with picture_url", async () => {
28
+ const fetchMock = vi.fn(async () => new Response("", { status: 202, statusText: "Accepted" }));
29
+
30
+ await sendGroupMeMessage({
31
+ botId: "bot-1",
32
+ text: "image",
33
+ pictureUrl: "https://i.groupme.com/abc",
34
+ fetchFn: fetchMock as unknown as typeof fetch,
35
+ });
36
+
37
+ const [, options] = fetchMock.mock.calls[0] as [string, RequestInit];
38
+ const body = JSON.parse(String(options.body));
39
+ expect(body.picture_url).toBe("https://i.groupme.com/abc");
40
+ });
41
+
42
+ it("throws on API error", async () => {
43
+ const fetchMock = vi.fn(
44
+ async () => new Response("bad", { status: 400, statusText: "Bad Request" }),
45
+ );
46
+
47
+ await expect(
48
+ sendGroupMeMessage({
49
+ botId: "bot-1",
50
+ text: "hello",
51
+ fetchFn: fetchMock as unknown as typeof fetch,
52
+ }),
53
+ ).rejects.toThrow("GroupMe API error");
54
+ });
55
+ });
56
+
57
+ describe("uploadGroupMeImage", () => {
58
+ it("uploads and returns picture_url", async () => {
59
+ const fetchMock = vi.fn(
60
+ async () =>
61
+ new Response(JSON.stringify({ payload: { picture_url: "https://i.groupme.com/pic" } }), {
62
+ status: 200,
63
+ }),
64
+ );
65
+
66
+ const result = await uploadGroupMeImage({
67
+ accessToken: "token",
68
+ imageData: Buffer.from("abc"),
69
+ fetchFn: fetchMock as unknown as typeof fetch,
70
+ });
71
+
72
+ expect(result).toBe("https://i.groupme.com/pic");
73
+ });
74
+
75
+ it("throws when picture_url is missing", async () => {
76
+ const fetchMock = vi.fn(
77
+ async () =>
78
+ new Response(JSON.stringify({ payload: {} }), {
79
+ status: 200,
80
+ }),
81
+ );
82
+
83
+ await expect(
84
+ uploadGroupMeImage({
85
+ accessToken: "token",
86
+ imageData: Buffer.from("abc"),
87
+ fetchFn: fetchMock as unknown as typeof fetch,
88
+ }),
89
+ ).rejects.toThrow("no picture_url");
90
+ });
91
+ });
92
+
93
+ describe("high-level send helpers", () => {
94
+ it("sends text using resolved account", async () => {
95
+ const cfg: CoreConfig = {
96
+ channels: {
97
+ groupme: {
98
+ botId: "bot-1",
99
+ },
100
+ },
101
+ };
102
+
103
+ const fetchMock = vi.fn(async () => new Response("", { status: 201 }));
104
+
105
+ await sendGroupMeText({
106
+ cfg,
107
+ to: "any",
108
+ text: "hello",
109
+ fetchFn: fetchMock as unknown as typeof fetch,
110
+ });
111
+
112
+ expect(fetchMock).toHaveBeenCalledTimes(1);
113
+ });
114
+
115
+ it("sends media by downloading then uploading", async () => {
116
+ const cfg: CoreConfig = {
117
+ channels: {
118
+ groupme: {
119
+ botId: "bot-1",
120
+ accessToken: "token-1",
121
+ },
122
+ },
123
+ };
124
+
125
+ const fetchMock = vi
126
+ .fn()
127
+ .mockResolvedValueOnce(
128
+ new Response(Buffer.from("img"), {
129
+ status: 200,
130
+ headers: { "content-type": "image/png" },
131
+ }),
132
+ )
133
+ .mockResolvedValueOnce(
134
+ new Response(JSON.stringify({ payload: { picture_url: "https://i.groupme.com/new" } }), {
135
+ status: 200,
136
+ }),
137
+ )
138
+ .mockResolvedValueOnce(new Response("", { status: 201 }));
139
+
140
+ await sendGroupMeMedia({
141
+ cfg,
142
+ to: "any",
143
+ text: "caption",
144
+ mediaUrl: "https://example.com/image.png",
145
+ fetchFn: fetchMock as unknown as typeof fetch,
146
+ });
147
+
148
+ expect(fetchMock).toHaveBeenCalledTimes(3);
149
+ expect(fetchMock.mock.calls[0]?.[0]).toBe("https://example.com/image.png");
150
+ expect(fetchMock.mock.calls[1]?.[0]).toBe("https://image.groupme.com/pictures");
151
+ expect(fetchMock.mock.calls[2]?.[0]).toBe("https://api.groupme.com/v3/bots/post");
152
+ });
153
+ });
package/src/send.ts ADDED
@@ -0,0 +1,194 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { CoreConfig } from "./types.js";
3
+ import { resolveGroupMeAccount } from "./accounts.js";
4
+
5
+ export const GROUPME_API_BASE = "https://api.groupme.com/v3";
6
+ export const GROUPME_IMAGE_SERVICE = "https://image.groupme.com";
7
+ export const GROUPME_MAX_TEXT_LENGTH = 1000;
8
+
9
+ export type SendGroupMeResult = {
10
+ messageId: string;
11
+ timestamp: number;
12
+ };
13
+
14
+ type FetchLike = typeof fetch;
15
+
16
+ type GroupMeBotPostPayload = {
17
+ bot_id: string;
18
+ text: string;
19
+ picture_url?: string;
20
+ };
21
+
22
+ function buildGroupMeBotPostPayload(params: {
23
+ botId: string;
24
+ text: string;
25
+ pictureUrl?: string;
26
+ }): GroupMeBotPostPayload {
27
+ const payload: GroupMeBotPostPayload = {
28
+ bot_id: params.botId,
29
+ text: params.text,
30
+ };
31
+ if (params.pictureUrl) {
32
+ payload.picture_url = params.pictureUrl;
33
+ }
34
+ return payload;
35
+ }
36
+
37
+ export async function sendGroupMeMessage(params: {
38
+ botId: string;
39
+ text: string;
40
+ pictureUrl?: string;
41
+ fetchFn?: FetchLike;
42
+ }): Promise<SendGroupMeResult> {
43
+ const fetchFn = params.fetchFn ?? fetch;
44
+ const response = await fetchFn(`${GROUPME_API_BASE}/bots/post`, {
45
+ method: "POST",
46
+ headers: {
47
+ "Content-Type": "application/json",
48
+ },
49
+ body: JSON.stringify(
50
+ buildGroupMeBotPostPayload({
51
+ botId: params.botId,
52
+ text: params.text,
53
+ pictureUrl: params.pictureUrl,
54
+ }),
55
+ ),
56
+ });
57
+
58
+ if (!response.ok) {
59
+ throw new Error(`GroupMe API error: ${response.status} ${response.statusText}`);
60
+ }
61
+
62
+ return {
63
+ messageId: randomUUID(),
64
+ timestamp: Date.now(),
65
+ };
66
+ }
67
+
68
+ function extractPictureUrl(value: unknown): string | null {
69
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
70
+ return null;
71
+ }
72
+
73
+ const payload = (value as { payload?: unknown }).payload;
74
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
75
+ return null;
76
+ }
77
+
78
+ const pictureUrl = (payload as { picture_url?: unknown }).picture_url;
79
+ if (typeof pictureUrl !== "string") {
80
+ return null;
81
+ }
82
+
83
+ const trimmed = pictureUrl.trim();
84
+ return trimmed || null;
85
+ }
86
+
87
+ export async function uploadGroupMeImage(params: {
88
+ accessToken: string;
89
+ imageData: Buffer;
90
+ contentType?: string;
91
+ fetchFn?: FetchLike;
92
+ }): Promise<string> {
93
+ const fetchFn = params.fetchFn ?? fetch;
94
+ const response = await fetchFn(`${GROUPME_IMAGE_SERVICE}/pictures`, {
95
+ method: "POST",
96
+ headers: {
97
+ "X-Access-Token": params.accessToken,
98
+ "Content-Type": params.contentType ?? "image/jpeg",
99
+ },
100
+ body: new Uint8Array(params.imageData),
101
+ });
102
+
103
+ if (!response.ok) {
104
+ throw new Error(`GroupMe image upload failed: ${response.status}`);
105
+ }
106
+
107
+ const json = (await response.json()) as unknown;
108
+ const pictureUrl = extractPictureUrl(json);
109
+ if (!pictureUrl) {
110
+ throw new Error("GroupMe image upload: no picture_url in response");
111
+ }
112
+
113
+ return pictureUrl;
114
+ }
115
+
116
+ async function downloadRemoteMedia(params: {
117
+ mediaUrl: string;
118
+ fetchFn?: FetchLike;
119
+ }): Promise<{ data: Buffer; contentType: string }> {
120
+ const fetchFn = params.fetchFn ?? fetch;
121
+ const response = await fetchFn(params.mediaUrl);
122
+ if (!response.ok) {
123
+ throw new Error(`GroupMe media download failed: ${response.status} ${response.statusText}`);
124
+ }
125
+
126
+ const contentType = response.headers.get("content-type") || "image/jpeg";
127
+ const data = Buffer.from(await response.arrayBuffer());
128
+
129
+ return { data, contentType };
130
+ }
131
+
132
+ export async function sendGroupMeText(params: {
133
+ cfg: CoreConfig;
134
+ to: string;
135
+ text: string;
136
+ accountId?: string | null;
137
+ fetchFn?: FetchLike;
138
+ }): Promise<SendGroupMeResult> {
139
+ const account = resolveGroupMeAccount({
140
+ cfg: params.cfg,
141
+ accountId: params.accountId,
142
+ });
143
+ if (!account.botId) {
144
+ throw new Error(`GroupMe account "${account.accountId}" is missing botId`);
145
+ }
146
+
147
+ return sendGroupMeMessage({
148
+ botId: account.botId,
149
+ text: params.text,
150
+ fetchFn: params.fetchFn,
151
+ });
152
+ }
153
+
154
+ export async function sendGroupMeMedia(params: {
155
+ cfg: CoreConfig;
156
+ to: string;
157
+ text: string;
158
+ mediaUrl: string;
159
+ accountId?: string | null;
160
+ fetchFn?: FetchLike;
161
+ }): Promise<SendGroupMeResult> {
162
+ const account = resolveGroupMeAccount({
163
+ cfg: params.cfg,
164
+ accountId: params.accountId,
165
+ });
166
+
167
+ if (!account.botId) {
168
+ throw new Error(`GroupMe account "${account.accountId}" is missing botId`);
169
+ }
170
+ if (!account.accessToken) {
171
+ throw new Error(
172
+ `GroupMe account "${account.accountId}" is missing accessToken required for image uploads`,
173
+ );
174
+ }
175
+
176
+ const { data, contentType } = await downloadRemoteMedia({
177
+ mediaUrl: params.mediaUrl,
178
+ fetchFn: params.fetchFn,
179
+ });
180
+
181
+ const pictureUrl = await uploadGroupMeImage({
182
+ accessToken: account.accessToken,
183
+ imageData: data,
184
+ contentType,
185
+ fetchFn: params.fetchFn,
186
+ });
187
+
188
+ return sendGroupMeMessage({
189
+ botId: account.botId,
190
+ text: params.text,
191
+ pictureUrl,
192
+ fetchFn: params.fetchFn,
193
+ });
194
+ }
package/src/types.ts ADDED
@@ -0,0 +1,103 @@
1
+ import type {
2
+ BlockStreamingCoalesceConfig,
3
+ MarkdownConfig,
4
+ OpenClawConfig,
5
+ } from "openclaw/plugin-sdk";
6
+
7
+ export type GroupMeAllowFromEntry = string | number;
8
+
9
+ export type GroupMeAccountConfig = {
10
+ name?: string;
11
+ enabled?: boolean;
12
+ botId?: string;
13
+ accessToken?: string;
14
+ botName?: string;
15
+ callbackPath?: string;
16
+ mentionPatterns?: string[];
17
+ requireMention?: boolean;
18
+ allowFrom?: GroupMeAllowFromEntry[];
19
+ markdown?: MarkdownConfig;
20
+ textChunkLimit?: number;
21
+ blockStreaming?: boolean;
22
+ blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
23
+ responsePrefix?: string;
24
+ mediaMaxMb?: number;
25
+ };
26
+
27
+ export type GroupMeConfig = GroupMeAccountConfig & {
28
+ accounts?: Record<string, GroupMeAccountConfig | undefined>;
29
+ defaultAccount?: string;
30
+ };
31
+
32
+ export type CoreConfig = OpenClawConfig & {
33
+ channels?: OpenClawConfig["channels"] & {
34
+ groupme?: GroupMeConfig;
35
+ };
36
+ };
37
+
38
+ export type ResolvedGroupMeAccount = {
39
+ accountId: string;
40
+ name?: string;
41
+ enabled: boolean;
42
+ configured: boolean;
43
+ botId: string;
44
+ accessToken: string;
45
+ config: GroupMeAccountConfig;
46
+ };
47
+
48
+ export type GroupMeImageAttachment = {
49
+ type: "image";
50
+ url: string;
51
+ };
52
+
53
+ export type GroupMeLocationAttachment = {
54
+ type: "location";
55
+ lat: string;
56
+ lng: string;
57
+ name: string;
58
+ };
59
+
60
+ export type GroupMeMentionsAttachment = {
61
+ type: "mentions";
62
+ user_ids: string[];
63
+ loci: number[][];
64
+ };
65
+
66
+ export type GroupMeEmojiAttachment = {
67
+ type: "emoji";
68
+ placeholder: string;
69
+ charmap: number[][];
70
+ };
71
+
72
+ export type GroupMeUnknownAttachment = {
73
+ type: string;
74
+ [key: string]: unknown;
75
+ };
76
+
77
+ export type GroupMeAttachment =
78
+ | GroupMeImageAttachment
79
+ | GroupMeLocationAttachment
80
+ | GroupMeMentionsAttachment
81
+ | GroupMeEmojiAttachment
82
+ | GroupMeUnknownAttachment;
83
+
84
+ export type GroupMeCallbackData = {
85
+ id: string;
86
+ text: string;
87
+ name: string;
88
+ senderType: string;
89
+ senderId: string;
90
+ userId: string;
91
+ groupId: string;
92
+ sourceGuid: string;
93
+ createdAt: number;
94
+ system: boolean;
95
+ avatarUrl: string | null;
96
+ attachments: GroupMeAttachment[];
97
+ };
98
+
99
+ export type GroupMeProbe = {
100
+ ok: boolean;
101
+ botId?: string;
102
+ error?: string;
103
+ };