Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,18 @@ inputs:
required: true
description: "slack bot token with required scopes"
googleClientEmail:
required: true
description: "email of google service account"
required: false
description: "email of google service account (required for gsheet format)"
googlePrivateKey:
required: true
description: "private_key for google service account"
required: false
description: "private_key for google service account (required for gsheet format)"
timezone:
required: true
description: "your current timezone"
default: "Asia/Tokyo"
folderId:
required: true
description: "google drive folder id"
required: false
description: "google drive folder id (required for gsheet format)"
year:
required: false
description: "target year"
Expand All @@ -31,11 +31,15 @@ inputs:
skipChannels:
required: false
description: Array of channel id which should skip
format:
required: false
description: "Output format: gsheet, json, markdown"
default: "gsheet"
runs:
using: composite
steps:
- name: Setup Deno
uses: denoland/setup-deno@27e0043effb637fb8409496e05bd8472e4b87554 #v2.0.2
uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb #v2.0.3
with:
deno-version: v2.x
- name: Deno Run
Expand All @@ -51,3 +55,4 @@ runs:
INPUT_MONTH: ${{ inputs.month }}
INPUT_AUTOJOIN: ${{ inputs.autoJoin }}
INPUT_SKIPCHANNELS: ${{ inputs.skipChannels }}
INPUT_FORMAT: ${{ inputs.format }}
6 changes: 5 additions & 1 deletion deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@
"@actions/core": "npm:@actions/core@^1.11.1",
"@seratch/slack-web-api-client": "jsr:@seratch/slack-web-api-client@^1.1.5",
"@slack/types": "npm:@slack/types@^2.14.0",
"@std/cli": "jsr:@std/cli@^1",
"google-auth-library": "npm:google-auth-library@^9.15.1",
"googleapis": "npm:googleapis@^148.0.0"
},
"unstable": [
"temporal"
]
],
"tasks": {
"cli": "deno run -q -A src/cli.ts"
}
}
22 changes: 17 additions & 5 deletions src/action.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import main from "./main.ts";
import textMain from "./textMain.ts";
import settings from "./settings.ts";
import * as core from "@actions/core";

Expand Down Expand Up @@ -29,8 +30,19 @@ if (isNaN(year)) {
core.notice(`start backing up ${from.year}/${from.month}`);
const to = from.add({ months: 1 });

main(false, new Date(from.epochMilliseconds), new Date(to.epochMilliseconds))
.catch((e) => {
console.error(e);
core.setFailed(`Action failed with error ${e}`);
});
const fromDate = new Date(from.epochMilliseconds);
const toDate = new Date(to.epochMilliseconds);
const format = settings.format;
if (format !== "json" && format !== "markdown" && format !== "gsheet") {
core.setFailed(`Invalid format "${format}". Use json, markdown, or gsheet.`);
Deno.exit(1);
}

const run = format === "json" || format === "markdown"
? textMain(format, fromDate, toDate)
: main(false, fromDate, toDate);

run.catch((e) => {
console.error(e);
core.setFailed(`Action failed with error ${e}`);
});
168 changes: 168 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { parseArgs } from "@std/cli/parse-args";

const args = parseArgs(Deno.args, {
string: [
"slack-token",
"timezone",
"format",
"year",
"month",
"folder-id",
"google-client-email",
"google-private-key",
"skip-channels",
],
boolean: ["auto-join", "help"],
default: {
"auto-join": true,
format: "json",
},
negatable: ["auto-join"],
alias: {
h: "help",
f: "format",
t: "timezone",
y: "year",
m: "month",
},
});

if (args.help) {
console.log(`Usage: deno run -A src/cli.ts [options]

Output format:
--format, -f <format> Output format: json, markdown, gsheet (default: json)

Date range:
--year, -y <year> Target year (default: 2 months ago)
--month, -m <month> Target month (default: 2 months ago)
--timezone, -t <timezone> Timezone (default: UTC)

Slack:
--slack-token <token> Slack bot token (or SLACK_TOKEN env var)
--auto-join / --no-auto-join Auto-join public channels (default: true)
--skip-channels <ids> Space-separated channel IDs to skip

Google Sheets (required for --format gsheet):
--folder-id <id> Google Drive folder ID (or FOLDER_ID env var)
--google-client-email <email> Google service account email (or GOOGLE_CLIENT_EMAIL env var)
--google-private-key <key> Google service account private key (or GOOGLE_PRIVATE_KEY env var)

--help, -h Show this help`);
Deno.exit(0);
}

const slackToken = args["slack-token"] || Deno.env.get("SLACK_TOKEN") ||
Deno.env.get("INPUT_SLACKTOKEN") || "";
const timezone = args["timezone"] || Deno.env.get("TIMEZONE") ||
Deno.env.get("INPUT_TIMEZONE") || "UTC";
const year = args["year"] || Deno.env.get("YEAR") || Deno.env.get("INPUT_YEAR") ||
"";
const month = args["month"] || Deno.env.get("MONTH") ||
Deno.env.get("INPUT_MONTH") || "";
const folderId = args["folder-id"] || Deno.env.get("FOLDER_ID") ||
Deno.env.get("INPUT_FOLDERID") || "";
const googleEmail = args["google-client-email"] ||
Deno.env.get("GOOGLE_CLIENT_EMAIL") ||
Deno.env.get("INPUT_GOOGLECLIENTEMAIL") || "";
const googleKey = args["google-private-key"] ||
Deno.env.get("GOOGLE_PRIVATE_KEY") ||
Deno.env.get("INPUT_GOOGLEPRIVATEKEY") || "";
const skipChannels = args["skip-channels"] || Deno.env.get("SKIP_CHANNELS") ||
Deno.env.get("INPUT_SKIPCHANNELS") || "";
const format = args["format"] || Deno.env.get("FORMAT") || "json";
const autoJoin = args["auto-join"] ?? true;

if (!slackToken) {
console.error(
"Error: Slack token is required. Use --slack-token or set SLACK_TOKEN env var.",
);
Deno.exit(1);
}

if (format === "gsheet") {
if (!folderId) {
console.error(
"Error: --folder-id is required for gsheet format. Use --folder-id or set FOLDER_ID env var.",
);
Deno.exit(1);
}
if (!googleEmail) {
console.error(
"Error: --google-client-email is required for gsheet format. Use --google-client-email or set GOOGLE_CLIENT_EMAIL env var.",
);
Deno.exit(1);
}
if (!googleKey) {
console.error(
"Error: --google-private-key is required for gsheet format. Use --google-private-key or set GOOGLE_PRIVATE_KEY env var.",
);
Deno.exit(1);
}
}

if (format !== "json" && format !== "markdown" && format !== "gsheet") {
console.error(
`Error: Unknown format "${format}". Use json, markdown, or gsheet.`,
);
Deno.exit(1);
}

// Set INPUT_ env vars so that settings.ts (loaded by downstream modules) picks them up
Deno.env.set("INPUT_SLACKTOKEN", slackToken);
Deno.env.set("INPUT_TIMEZONE", timezone);
if (year) Deno.env.set("INPUT_YEAR", year);
if (month) Deno.env.set("INPUT_MONTH", month);
if (folderId) Deno.env.set("INPUT_FOLDERID", folderId);
if (googleEmail) Deno.env.set("INPUT_GOOGLECLIENTEMAIL", googleEmail);
if (googleKey) Deno.env.set("INPUT_GOOGLEPRIVATEKEY", googleKey);
if (skipChannels) Deno.env.set("INPUT_SKIPCHANNELS", skipChannels);
Deno.env.set("INPUT_AUTOJOIN", autoJoin ? "true" : "false");
Deno.env.set("INPUT_FORMAT", format);

// Calculate date range using Temporal API
const yearNum = parseInt(year);
const monthNum = parseInt(month);
let from: Date;
let to: Date;

if (isNaN(yearNum) || isNaN(monthNum)) {
const now = Temporal.Now.zonedDateTimeISO(timezone).toPlainDate()
.toPlainYearMonth();
const twoMonths = Temporal.Duration.from({ months: 2 });
const fromZoned = now.subtract(twoMonths).toPlainDate({ day: 1 })
.toZonedDateTime(timezone);
from = new Date(fromZoned.epochMilliseconds);
to = new Date(fromZoned.add({ months: 1 }).epochMilliseconds);
} else {
const fromZoned = Temporal.PlainDateTime.from({
year: yearNum,
month: monthNum,
day: 1,
}).toZonedDateTime(timezone);
from = new Date(fromZoned.epochMilliseconds);
to = new Date(fromZoned.add({ months: 1 }).epochMilliseconds);
}

console.error(
`Fetching Slack logs from ${from.toISOString().slice(0, 10)} to ${
to.toISOString().slice(0, 10)
} (format: ${format})`,
);

// Dynamic imports ensure settings.ts is evaluated after env vars are set
if (format === "gsheet") {
const { default: main } = await import("./main.ts");
await main(false, from, to).catch((e: unknown) => {
console.error(e);
Deno.exit(1);
});
} else {
const { default: textMain } = await import("./textMain.ts");
await textMain(format as "json" | "markdown", from, to).catch(
(e: unknown) => {
console.error(e);
Deno.exit(1);
},
);
}
64 changes: 64 additions & 0 deletions src/lib/textOutput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Message, MessageProcessor } from "./slack.ts";
import { Timestamp } from "./timestamp.ts";

export type MessageRecord = {
thread: string;
ts: string;
user: string;
text: string;
};

export function msgToRecord(msg: Message, p: MessageProcessor): MessageRecord {
const { ts, user, text, ...rest } = msg;
const threadMark = msg.reply_count ? "+" : msg.parent_user_id ? ">" : "";
return {
thread: threadMark,
ts: ts!,
user: p.username(user) || rest.username || "",
text: p.readable(text) || rest.attachments?.[0]?.fallback || "",
};
}

export function recordsToJson(
channels: { name: string; records: MessageRecord[] }[],
): string {
const output = channels.map(({ name, records }) => ({
channel: name,
messages: records.map((r) => ({
thread: r.thread,
datetime: Timestamp.fromSlack(r.ts)!.toISOString(),
user: r.user,
text: r.text,
})),
}));
return JSON.stringify(output, null, 2);
}

export function recordsToMarkdown(
channels: { name: string; records: MessageRecord[] }[],
tz: string,
): string {
const lines: string[] = [];
for (const { name, records } of channels) {
if (records.length === 0) continue;
lines.push(`# #${name}`);
lines.push("");
let lastDate = "";
for (const r of records) {
const ts = Timestamp.fromSlack(r.ts)!;
const date = ts.date(tz);
if (date !== lastDate) {
if (lastDate !== "") lines.push("");
lines.push(`## ${date}`);
lines.push("");
lastDate = date;
}
const time = ts.hourMin(tz);
const indent = r.thread === ">" ? " " : "";
const prefix = r.thread ? `${r.thread} ` : "";
lines.push(`${indent}${time} ${prefix}${r.user}: ${r.text}`);
}
lines.push("");
}
return lines.join("\n");
}
21 changes: 13 additions & 8 deletions src/settings.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
export default {
slack: {
token: Deno.env.get("INPUT_SLACKTOKEN")!,
token: Deno.env.get("INPUT_SLACKTOKEN") || Deno.env.get("SLACK_TOKEN") ||
"",
},
google: {
email: Deno.env.get("INPUT_GOOGLECLIENTEMAIL")!,
key: Deno.env.get("INPUT_GOOGLEPRIVATEKEY")!,
email: Deno.env.get("INPUT_GOOGLECLIENTEMAIL") ||
Deno.env.get("GOOGLE_CLIENT_EMAIL") || "",
key: Deno.env.get("INPUT_GOOGLEPRIVATEKEY") ||
Deno.env.get("GOOGLE_PRIVATE_KEY") || "",
},
tz: Deno.env.get("INPUT_TIMEZONE")!,
folder: Deno.env.get("INPUT_FOLDERID")!,
year: Deno.env.get("INPUT_YEAR"),
month: Deno.env.get("INPUT_MONTH"),
tz: Deno.env.get("INPUT_TIMEZONE") || Deno.env.get("TIMEZONE") || "UTC",
folder: Deno.env.get("INPUT_FOLDERID") || Deno.env.get("FOLDER_ID") || "",
year: Deno.env.get("INPUT_YEAR") || Deno.env.get("YEAR"),
month: Deno.env.get("INPUT_MONTH") || Deno.env.get("MONTH"),
autoJoin: Deno.env.get("INPUT_AUTOJOIN") == "true",
skipChannels: (Deno.env.get("INPUT_SKIPCHANNELS") || "").split(" "),
skipChannels: (Deno.env.get("INPUT_SKIPCHANNELS") ||
Deno.env.get("SKIP_CHANNELS") || "").split(" "),
format: Deno.env.get("INPUT_FORMAT") || Deno.env.get("FORMAT") || "gsheet",
};
32 changes: 32 additions & 0 deletions src/textMain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
Deno.env.set("TZ", "UTC");
import settings from "./settings.ts";
import { channelsIt, historyIt, MessageProcessor } from "./lib/slack.ts";
import { msgToRecord, recordsToJson, recordsToMarkdown } from "./lib/textOutput.ts";
import { Timestamp } from "./lib/timestamp.ts";

export default async function textMain(
format: "json" | "markdown",
oldest_: Date,
latest_: Date,
) {
const oldest = new Timestamp(oldest_);
const latest = new Timestamp(latest_);
const messageProcessor = await new MessageProcessor().asyncInit();
const channels: { name: string; records: ReturnType<typeof msgToRecord>[] }[] =
[];
for await (const c of channelsIt()) {
console.error(`Processing: ${c.name}`);
const records: ReturnType<typeof msgToRecord>[] = [];
for await (const msg of historyIt(c.id!, oldest.slack(), latest.slack())) {
records.push(msgToRecord(msg, messageProcessor));
}
if (records.length > 0) {
channels.push({ name: c.name!, records });
}
}
if (format === "json") {
console.log(recordsToJson(channels));
} else {
console.log(recordsToMarkdown(channels, settings.tz));
}
}