Skip to content

Commit 0019baf

Browse files
snomiaoclaudeCopilot
authored
feat: Add Slack DM functionality and cached Slack client (#74)
* feat: Add Slack DM functionality and cached Slack client - Implement Slack DM sending capability for weekly notifications - Add cached Slack API wrapper to reduce API rate limiting - Create SQLite-based caching for Slack API responses with 30-minute TTL in dev - Add ability to fetch users, open conversations, and send messages to specific users - Include utility to get permalink for last message in conversation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * Update src/slack/slackCached.ts Co-authored-by: Copilot <[email protected]> --------- Co-authored-by: Claude <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent cdc176e commit 0019baf

File tree

4 files changed

+202
-7
lines changed

4 files changed

+202
-7
lines changed

bun.lock

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@
8585
"react-intersection-observer": "^9.16.0",
8686
"react-markdown": "^9.0.1",
8787
"remark-gfm": "^4.0.0",
88-
"sflow": "^1.24.0",
88+
"sflow": "^1.25.1",
8989
"sha256": "^0.2.0",
9090
"sparse-bitfield": "^3.0.3",
9191
"swr": "^2.3.4",
@@ -1739,7 +1739,7 @@
17391739

17401740
"gzip-size": ["[email protected]", "", { "dependencies": { "duplexer": "^0.1.2" } }, "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q=="],
17411741

1742-
"h3": ["[email protected].1", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.3", "defu": "^6.1.4", "destr": "^2.0.3", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.0", "radix3": "^1.1.2", "ufo": "^1.5.4", "uncrypto": "^0.1.3" } }, "sha512-+ORaOBttdUm1E2Uu/obAyCguiI7MbBvsLTndc3gyK3zU+SYLoZXlyCP9Xgy0gikkGufFLTZXCXD6+4BsufnmHA=="],
1742+
"h3": ["[email protected].4", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.2", "radix3": "^1.1.2", "ufo": "^1.6.1", "uncrypto": "^0.1.3" } }, "sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ=="],
17431743

17441744
"has-bigints": ["[email protected]", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
17451745

@@ -2287,7 +2287,7 @@
22872287

22882288
"octokit": ["[email protected]", "", { "dependencies": { "@octokit/app": "^15.1.6", "@octokit/core": "^6.1.5", "@octokit/oauth-app": "^7.1.6", "@octokit/plugin-paginate-graphql": "^5.2.4", "@octokit/plugin-paginate-rest": "^12.0.0", "@octokit/plugin-rest-endpoint-methods": "^14.0.0", "@octokit/plugin-retry": "^7.2.1", "@octokit/plugin-throttling": "^10.0.0", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "@octokit/webhooks": "^13.8.3" } }, "sha512-cRvxRte6FU3vAHRC9+PMSY3D+mRAs2Rd9emMoqp70UGRvJRM3sbAoim2IXRZNNsf8wVfn4sGxVBHRAP+JBVX/g=="],
22892289

2290-
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
2290+
"once": ["once@1.3.3", "", { "dependencies": { "wrappy": "1" } }, "sha512-6vaNInhu+CHxtONf3zw3vq4SP2DOQhjBvIa3rNcG0+P7eKWlYH6Peu7rHizSloRU2EwMz6GraLieis9Ac9+p1w=="],
22912291

22922292
"one-time": ["[email protected]", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="],
22932293

@@ -3101,8 +3101,6 @@
31013101

31023102
"edge-runtime/signal-exit": ["[email protected]", "", {}, "sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q=="],
31033103

3104-
"end-of-stream/once": ["[email protected]", "", { "dependencies": { "wrappy": "1" } }, "sha512-6vaNInhu+CHxtONf3zw3vq4SP2DOQhjBvIa3rNcG0+P7eKWlYH6Peu7rHizSloRU2EwMz6GraLieis9Ac9+p1w=="],
3105-
31063104
"eslint/chalk": ["[email protected]", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
31073105

31083106
"eslint-import-resolver-node/debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
@@ -3309,6 +3307,8 @@
33093307

33103308
"tinyglobby/picomatch": ["[email protected]", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
33113309

3310+
"trpc-to-openapi/h3": ["[email protected]", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.3", "defu": "^6.1.4", "destr": "^2.0.3", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.0", "radix3": "^1.1.2", "ufo": "^1.5.4", "uncrypto": "^0.1.3" } }, "sha512-+ORaOBttdUm1E2Uu/obAyCguiI7MbBvsLTndc3gyK3zU+SYLoZXlyCP9Xgy0gikkGufFLTZXCXD6+4BsufnmHA=="],
3311+
33123312
"ts-node/arg": ["[email protected]", "", {}, "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg=="],
33133313

33143314
"ts-node/diff": ["[email protected]", "", {}, "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A=="],
@@ -3329,7 +3329,7 @@
33293329

33303330
"wrap-ansi-cjs/string-width": ["[email protected]", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
33313331

3332-
"write-file-atomic/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
3332+
"write-file-atomic/signal-exit": ["signal-exit@4.0.2", "", {}, "sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q=="],
33333333

33343334
"zrender/tslib": ["[email protected]", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="],
33353335

@@ -3499,6 +3499,8 @@
34993499

35003500
"string-width/strip-ansi/ansi-regex": ["[email protected]", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
35013501

3502+
"tar-stream/end-of-stream/once": ["[email protected]", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
3503+
35023504
"vercel/chokidar/fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ=="],
35033505

35043506
"vercel/chokidar/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@
137137
"react-intersection-observer": "^9.16.0",
138138
"react-markdown": "^9.0.1",
139139
"remark-gfm": "^4.0.0",
140-
"sflow": "^1.24.0",
140+
"sflow": "^1.25.1",
141141
"sha256": "^0.2.0",
142142
"sparse-bitfield": "^3.0.3",
143143
"swr": "^2.3.4",

src/slack/dm.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { upsertSlackMessage } from "@/app/tasks/gh-desktop-release-notification/upsertSlackMessage";
2+
import DIE from "@snomiao/die";
3+
import { pageFlow } from "sflow";
4+
import { slack } from ".";
5+
import { slackCached } from "./slackCached";
6+
7+
// export type SlackWeeklyDM = {
8+
// member
9+
// }
10+
// export const SlackWeeklyDM =db.collection
11+
12+
if (import.meta.main) {
13+
// await pageFlow(undefined as undefined | string, async (cursor, limit = 100) => {
14+
// const res = await slackCached.conversations.list({ })
15+
// return { data: res.channels, next: res.response_metadata?.next_cursor || null }
16+
// }).flat()
17+
// .filter(e => JSON.stringify(e).match('snomiao'))
18+
// .forEach(member => {
19+
// // member.name
20+
// // slack.conversations.list()
21+
// }).log()
22+
// .run()
23+
await pageFlow(undefined as undefined | string, async (cursor, limit = 100) => {
24+
const res = await slackCached.users.list({ limit: 100, cursor });
25+
return { data: res.members, next: res.response_metadata?.next_cursor || null };
26+
})
27+
.flat()
28+
.filter((e) => e.name === "snomiao")
29+
// open DM
30+
// .by(mapMixins(async member => ({
31+
// a: (await slackCached.conversations.open({
32+
// users: [member.id].join(',') || DIE('fail to get id from member ' + JSON.stringify({ member }))
33+
// }))?.channel || DIE('fail to open conversation to member ' + member.name)
34+
// })))
35+
36+
// attach channel info to member
37+
.mapMixin(async (member) => ({
38+
channel:
39+
(await slackCached.conversations
40+
.open({
41+
users: [member.id].join(",") || DIE("fail to get id from member " + JSON.stringify({ member })),
42+
})
43+
.then((res) => res?.channel)) || DIE("fail to open conversation to member " + member.name),
44+
}))
45+
// send message
46+
.map(async (member) => {
47+
const chanId = member.channel.id || DIE("no channel id for " + JSON.stringify({ member }));
48+
const lastMessageUrl = await slack.conversations.history({ channel: chanId, limit: 1 }).then(async (res) => {
49+
const lastMessage = res.messages?.[0];
50+
if (!lastMessage) return;
51+
return await slack.chat
52+
.getPermalink({
53+
channel: chanId,
54+
message_ts:
55+
lastMessage?.ts || DIE("Got last message without chanId: " + JSON.stringify({ chanId, lastMessage })),
56+
})
57+
.then((res) => res.permalink || DIE("got empty permalink " + JSON.stringify({ res, chanId, lastMessage })));
58+
});
59+
await upsertSlackMessage({ channel: member.channel.id, text: "hello", url: lastMessageUrl });
60+
})
61+
// log result
62+
.log()
63+
.run();
64+
}

src/slack/slackCached.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import KeyvSqlite from "@keyv/sqlite";
2+
import { WebClient } from "@slack/web-api";
3+
import DIE from "@snomiao/die";
4+
import crypto from "crypto";
5+
import fs from "fs/promises";
6+
import stableStringify from "json-stable-stringify";
7+
import Keyv from "keyv";
8+
import path from "path";
9+
import { createLogger } from "../logger";
10+
11+
const logger = createLogger("slackCached");
12+
13+
const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN?.trim() || DIE("missing env.SLACK_BOT_TOKEN");
14+
const slack = new WebClient(SLACK_BOT_TOKEN);
15+
16+
const CACHE_DIR = path.join(process.cwd(), "node_modules/.cache/Comfy-PR");
17+
const CACHE_FILE = path.join(CACHE_DIR, "slack-cache.sqlite");
18+
const DEFAULT_TTL = process.env.LOCAL_DEV
19+
? 30 * 60 * 1000 // cache 30 minutes when local dev
20+
: 0 * 60 * 1000; // cache 0 minute in production
21+
22+
async function ensureCacheDir() {
23+
await fs.mkdir(CACHE_DIR, { recursive: true });
24+
}
25+
26+
let keyv: Keyv | null = null;
27+
28+
async function getKeyv() {
29+
if (!keyv) {
30+
await ensureCacheDir();
31+
keyv = new Keyv({
32+
store: new KeyvSqlite(CACHE_FILE),
33+
ttl: DEFAULT_TTL,
34+
});
35+
}
36+
return keyv;
37+
}
38+
39+
type DeepAsyncWrapper<T> = {
40+
[K in keyof T]: T[K] extends (...args: any[]) => Promise<any>
41+
? T[K]
42+
: T[K] extends (...args: any[]) => any
43+
? (...args: Parameters<T[K]>) => Promise<ReturnType<T[K]>>
44+
: T[K] extends object
45+
? DeepAsyncWrapper<T[K]>
46+
: T[K];
47+
};
48+
function createCachedProxy<T extends object>(target: T, basePath: string[] = []): DeepAsyncWrapper<T> {
49+
return new Proxy<T>(target, {
50+
get(obj, prop) {
51+
const value = (obj as any)[prop];
52+
53+
if (typeof value === "function") {
54+
return async function (...args: any[]) {
55+
const cacheKey = createCacheKey(basePath, prop, args);
56+
const keyvInstance = await getKeyv();
57+
58+
// Try to get from cache first
59+
const cached = await keyvInstance.get(cacheKey);
60+
if (cached !== undefined) {
61+
// logger.debug(`Cache hit`, { cacheKey }); // cache hit info for debug
62+
return cached;
63+
}
64+
65+
// Call the original function
66+
const result = await value.apply(obj, args);
67+
68+
// Cache the result
69+
await keyvInstance.set(cacheKey, result);
70+
71+
return result;
72+
};
73+
} else if (typeof value === "object" && value !== null) {
74+
// Recursively wrap nested objects
75+
return createCachedProxy(value, [...basePath, prop.toString()]);
76+
}
77+
78+
return value;
79+
},
80+
}) as DeepAsyncWrapper<T> & {
81+
clear: () => Promise<void>;
82+
};
83+
async function clear(): Promise<void> {
84+
const keyvInstance = await getKeyv();
85+
await keyvInstance.clear();
86+
}
87+
function createCacheKey(basePath: string[], prop: string | symbol, args: any[]): string {
88+
// Create a deterministic key from the path and arguments
89+
const fullPath = [...basePath, prop.toString()];
90+
const apiPath = fullPath.join(".");
91+
92+
const argsText = args.map((e) => stableStringify(e)).join(",");
93+
const maxLength = 120 - apiPath.length - "gh.".length - 8 - 3; // Maximum length for args display
94+
95+
let displayArgs = argsText;
96+
if (argsText.length > maxLength) {
97+
const start = argsText.substring(0, maxLength / 2);
98+
const end = argsText.substring(argsText.length - maxLength / 2);
99+
displayArgs = `${start}...${end}`;
100+
}
101+
102+
const hash = crypto.createHash("md5").update(argsText).digest("hex").substring(0, 8);
103+
const cacheKey = `gh.${apiPath}(${displayArgs})#${hash}`;
104+
105+
return cacheKey;
106+
}
107+
}
108+
109+
export const slackCached = createCachedProxy(slack);
110+
111+
// manual test with real api
112+
if (import.meta.main) {
113+
async function runTest() {
114+
// Test the cached client
115+
logger.info("Testing cached Slack client...");
116+
117+
// This should make a real API call
118+
const result1 = await slack.users.profile.get({});
119+
logger.info("First call result", { name: result1.profile });
120+
121+
// This should use cache
122+
const result2 = await slack.users.profile.get({});
123+
logger.info("Second call result (cached)", { name: result2.profile });
124+
125+
logger.info("Cache test complete!");
126+
}
127+
128+
runTest().catch((error) => logger.error("Test failed", error));
129+
}

0 commit comments

Comments
 (0)