diff --git a/.env.example b/.env.example index 7c0b70df..2342bc9c 100644 --- a/.env.example +++ b/.env.example @@ -10,18 +10,13 @@ REDIS_URL=redis://localhost:6379 # Email magic links (Resend) RESEND_API_KEY=re_... -EMAIL_FROM=OpenReply +EMAIL_FROM=AwlChat # Optional: restrict who can sign in. Without it, anyone who reaches your # public URL can request a magic link and gets their own workspace. Comma # separated, case insensitive. # ALLOWED_EMAILS=you@example.com,teammate@example.com -# Optional: use your own SMTP server instead of Resend. When EMAIL_SERVER is -# set it takes over and RESEND_API_KEY is not needed. URL-encode special -# characters in user and password (@ becomes %40). -# EMAIL_SERVER=smtps://login%40example.com:password@mail.example.com:465 - # Meta / Instagram META_GRAPH_API_VERSION=v25.0 INSTAGRAM_APP_ID=your-instagram-app-id diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100644 index 00000000..8bc22625 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,2 @@ +#!/bin/sh +node scripts/check-commit-message.mjs "$1" diff --git a/.gitignore b/.gitignore index 15f6337c..86a1c8a3 100644 --- a/.gitignore +++ b/.gitignore @@ -30,10 +30,22 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* -# env files (can opt-in for committing if needed) +# env files (only the documented template may be committed) .env* !.env.example +# local credentials and secret material +*.key +*.p12 +*.pfx +**/*credential* +**/*secret* +**/*private-key* +**/*api-key* +**/*api_key* +**/*apikey* +**/*token* + # vercel .vercel diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f4a205c..9103e1be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -Thanks for wanting to help. OpenReply is public so the comment-to-DM engine is something you can read, run yourself, and improve. +Thanks for wanting to help. AwlChat is public so the comment-to-DM engine is something you can read, run yourself, and improve. ## Ways to help @@ -31,6 +31,7 @@ npm run worker Branch from `main`, keep the change focused on one thing, and make sure these pass: ```bash +node scripts/check-git-hygiene.mjs npm run typecheck npm run lint npm test @@ -39,6 +40,37 @@ npm run build If a check cannot run in your environment, say why in the pull request body. A small, clear pull request is easier to merge than a large one that touches many things at once. +## Commits and secret hygiene + +Use a Conventional Commit subject with one of these types: + +```text +feat: add a capability +fix: correct broken behavior +chore: maintain tooling or dependencies +docs: update documentation +refactor: change structure without changing behavior +test: add or update tests +``` + +Optional scopes are supported, for example: + +```text +feat(auth): restrict sign-in to allowed emails +``` + +The versioned `.githooks/commit-msg` hook validates this format. Enable it for +your local clone with: + +```bash +git config core.hooksPath .githooks +``` + +Before staging, run `node scripts/check-git-hygiene.mjs`. Stage only the files related to +the change, never `.env`, `.env.local`, credentials, or API keys, and run the +check again after staging. The checker rejects tracked environment files, +secret-named files, and common secret signatures in staged content. + ## A note on the codebase This is Next.js 16, and some conventions differ from older versions. There are dev notes in `AGENTS.md`. When you are unsure about an API, read the relevant guide in `node_modules/next/dist/docs/` before writing against it. diff --git a/README.md b/README.md index 964b2656..94057567 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,22 @@
-# OpenReply +# AwlChat -Open-sourced ManyChat for Instagram comment-to-DM automation. +Open-source ManyChat alternative for Instagram comment-to-DM automation. [![License: MIT](https://img.shields.io/badge/License-MIT-black.svg)](LICENSE) -[![Stars](https://img.shields.io/github/stars/diwenne/openreply?style=flat&color=black)](https://github.com/diwenne/openreply/stargazers) +[![Stars](https://img.shields.io/github/stars/treyyha/awlchat?style=flat&color=black)](https://github.com/treyyha/awlchat/stargazers) [![Built with Next.js](https://img.shields.io/badge/Next.js-16-black.svg)](https://nextjs.org)
-Someone comments `LINK` on your reel, and they get a DM with your link a second later. That is the whole idea. OpenReply watches the comments on your Instagram posts, and when a comment matches a keyword you set, it sends that person a private reply through the official Meta API. You can also post a public reply under the comment at the same time. +Someone comments `LINK` on your reel, and they get a DM with your link a second later. That is the whole idea. AwlChat watches the comments on your Instagram posts, and when a comment matches a keyword you set, it sends that person a private reply through the official Meta API. You can also post a public reply under the comment at the same time. -ManyChat does this and charges a monthly fee. OpenReply is the same core feature, free, running on your own infrastructure, with no seat limits and no plan caps. +ManyChat does this and charges a monthly fee. AwlChat provides the same core feature, free, running on your own infrastructure, with no seat limits and no plan caps. -> **OpenReply is self-hosted. You have to deploy your own copy.** +> **AwlChat is self-hosted. You have to deploy your own copy.** > -> [openreply.diwen.dev](https://openreply.diwen.dev) is a demo of the dashboard, not a service you can sign up for. Creating an account there will never send a DM for you, and there is no hosted plan to upgrade to. +> There is no shared AwlChat instance to sign up for. Deploy your own copy on your own domain so your Meta app can connect to it. > > Instagram automation runs against *your* Meta app, and Meta ties that app to a domain and a webhook URL you control. So a working instance means: your fork deployed, your domain pointed at it, your Meta app created, and your webhook registered. [docs/setup.md](docs/setup.md) walks through all of it. @@ -26,7 +26,7 @@ ManyChat does this and charges a monthly fee. OpenReply is the same core feature Comment-to-DM is one feature, but every tool that offers it wants a recurring subscription for it. The actual work is a webhook, a keyword match, and one API call to Meta. That does not need to cost anything to run for a single account. -OpenReply is built around Meta's official Instagram private replies. It does not scrape, it does not automate a browser, and it never asks for an Instagram password. That keeps your account inside Meta's rules, which matters if you care about not getting flagged. +AwlChat is built around Meta's official Instagram private replies. It does not scrape, it does not automate a browser, and it never asks for an Instagram password. That keeps your account inside Meta's rules, which matters if you care about not getting flagged. ## Features @@ -35,7 +35,7 @@ OpenReply is built around Meta's official Instagram private replies. It does not - DM and Story reply triggers. The same keywords can also fire on an inbound DM, which covers text replies to your Stories, since Instagram delivers those as DMs. That makes `Reply LINK to this Story` work with no post involved. Turn it on per campaign, and subscribe to the `messages` webhook field when you set up your Meta app. - Tracked links. Swap a link for a tracked redirect and see clicks and CTR per campaign. - Two link buttons. Send up to two tappable link buttons in one DM, each a separate tracked link with its own click stats. -- Follow gate. Optionally require a follow before you hand over the link. The DM asks the commenter to follow and tap a button; on tap, OpenReply checks Meta's `is_user_follow_business` flag and only sends the link once they follow, re-prompting until then. It fails open (sends the link anyway) when Instagram does not return follow status, so a real follower is never trapped. +- Follow gate. Optionally require a follow before you hand over the link. The DM asks the commenter to follow and tap a button; on tap, AwlChat checks Meta's `is_user_follow_business` flag and only sends the link once they follow, re-prompting until then. It fails open (sends the link anyway) when Instagram does not return follow status, so a real follower is never trapped. - Personalization. Use `{username}` in your message to greet the commenter by name. - Per-account rate limiting. Stays under Meta's documented cap of 750 private replies per hour, and queues the overflow instead of dropping it. - Multiple Instagram accounts. Connect several professional accounts under one workspace, each with its own limits. @@ -48,8 +48,8 @@ OpenReply is built around Meta's official Instagram private replies. It does not ## How it works 1. Someone comments on your Instagram post or reel, or DMs you, or replies to your Story. -2. Meta sends a webhook to your OpenReply instance. -3. OpenReply checks the text against your active campaigns. +2. Meta sends a webhook to your AwlChat instance. +3. AwlChat checks the text against your active campaigns. 4. On a keyword match, it queues a job. 5. A background worker sends the private reply, and the public reply if you enabled one. @@ -65,13 +65,13 @@ The honest version: the code deploys in minutes, but the Meta app setup is the p This is the part people skip. There is no shared instance to join — the button below creates *your* deployment, on *your* domain, which is the only thing your Meta app is allowed to talk to. -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/diwenne/openreply) +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/treyyha/awlchat) ### Run it locally ```bash -git clone https://github.com/diwenne/openreply.git -cd openreply +git clone https://github.com/treyyha/awlchat.git +cd awlchat npm install cp .env.example .env # then fill in the values, see docs/setup.md docker-compose up -d # starts Postgres and Redis @@ -114,11 +114,11 @@ Built and maintained by Diwen Huang. - X: [@diwenne](https://x.com/diwennee) - Instagram: [@devdiwen](https://instagram.com/devdiwen) -OpenReply was initially forked from [instagram-comment-to-dm](https://github.com/im-anishraj/instagram-comment-to-dm) by [Anish Raj](https://github.com/im-anishraj), also MIT licensed, and has been substantially built upon since. +AwlChat is based on OpenReply, which was initially forked from [instagram-comment-to-dm](https://github.com/im-anishraj/instagram-comment-to-dm) by [Anish Raj](https://github.com/im-anishraj), also MIT licensed, and has been substantially built upon since. OpenReply was built and maintained by [Diwen Huang](https://github.com/diwenne). ## Star the repo -If OpenReply is useful to you, star it. It is the simplest way to help the project reach the next person looking for a free way to do this. +If AwlChat is useful to you, star it. It is the simplest way to help the project reach the next person looking for a free way to do this. ## License diff --git a/SECURITY.md b/SECURITY.md index 30d9d4aa..0c2cb415 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,6 @@ # Security policy -OpenReply handles Instagram access tokens, webhook payloads, and campaign data. Please report security issues responsibly. +AwlChat handles Instagram access tokens, webhook payloads, and campaign data. Please report security issues responsibly. ## Supported versions diff --git a/__tests__/reports.test.ts b/__tests__/reports.test.ts index 8c39724e..33ae5a80 100644 --- a/__tests__/reports.test.ts +++ b/__tests__/reports.test.ts @@ -8,10 +8,12 @@ const { mockPrisma } = vi.hoisted(() => ({ dmLog: { groupBy: vi.fn(), findFirst: vi.fn(), + findMany: vi.fn(), count: vi.fn(), }, linkClick: { count: vi.fn(), + findMany: vi.fn(), }, }, })); @@ -68,6 +70,8 @@ beforeEach(() => { dmSentAt: new Date("2026-05-20T12:00:00.000Z"), createdAt: new Date("2026-05-20T12:00:00.000Z"), }); + mockPrisma.dmLog.findMany.mockResolvedValue([]); + mockPrisma.linkClick.findMany.mockResolvedValue([]); mockPrisma.dmLog.count.mockResolvedValue(2); }); diff --git a/__tests__/templates.test.ts b/__tests__/templates.test.ts index 5a9e8922..d5c84dd1 100644 --- a/__tests__/templates.test.ts +++ b/__tests__/templates.test.ts @@ -8,14 +8,14 @@ import { describe("campaign templates", () => { it("ships the public launch template set", () => { expect(getCampaignTemplateSlugs()).toEqual([ - "dtc-product-link", - "real-estate-lead-form", - "fitness-plan", - "course-webinar", - "beauty-price-list", - "restaurant-menu", - "event-rsvp", - "creator-media-kit", + "freebie-drop", + "digital-offer-link", + "waitlist-launch", + "discovery-call", + "newsletter-community", + "live-workshop", + "brand-collab-kit", + "content-os-stack", ]); }); @@ -30,9 +30,7 @@ describe("campaign templates", () => { }); it("finds templates by slug and returns null for unknown slugs", () => { - expect(getCampaignTemplate("dtc-product-link")?.title).toBe( - "DTC Product Link Drop" - ); + expect(getCampaignTemplate("freebie-drop")?.title).toBe("Freebie Drop"); expect(getCampaignTemplate("missing-template")).toBeNull(); expect(getCampaignTemplate(undefined)).toBeNull(); }); diff --git a/app/(dashboard)/campaigns/page.tsx b/app/(dashboard)/campaigns/page.tsx index 2420b0c9..07ae7533 100644 --- a/app/(dashboard)/campaigns/page.tsx +++ b/app/(dashboard)/campaigns/page.tsx @@ -546,7 +546,7 @@ export default function CampaignsPage() { onClick={() => toggleActive(auto.id, auto.isActive)} className={` relative w-11 h-6 rounded-full transition-colors - ${auto.isActive ? "bg-accent" : "bg-zinc-300"} + ${auto.isActive ? "bg-accent" : "bg-zinc-300 dark:bg-zinc-600"} `} > {/* Seven labels share a phone's width, so they must not wrap. */} - + {day.date} diff --git a/app/(dashboard)/inbox/page.tsx b/app/(dashboard)/inbox/page.tsx index 18dca4c2..e0598440 100644 --- a/app/(dashboard)/inbox/page.tsx +++ b/app/(dashboard)/inbox/page.tsx @@ -304,7 +304,7 @@ export default function InboxPage() { @{c.contact.username ?? "unknown"} - + {formatTime(c.updatedTime)} @@ -367,7 +367,7 @@ export default function InboxPage() {

{m.text}

{formatTime(m.createdTime)} diff --git a/app/(dashboard)/overview/page.tsx b/app/(dashboard)/overview/page.tsx index 24c3f233..9a60d02e 100644 --- a/app/(dashboard)/overview/page.tsx +++ b/app/(dashboard)/overview/page.tsx @@ -9,11 +9,15 @@ */ import { useEffect, useState } from "react"; +import dynamic from "next/dynamic"; import AccountSelect from "@/components/account-select"; import StatCard from "@/components/stat-card"; -import FollowerChart from "@/components/follower-chart"; import type { OverviewResponse } from "@/app/api/instagram/overview/route"; +const FollowerChart = dynamic(() => import("@/components/follower-chart"), { + loading: () =>

, +}); + function formatNumber(n: number | null): string { if (n === null) return "—"; if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; @@ -76,8 +80,8 @@ export default function OverviewPage() {
{[...Array(6)].map((_, i) => (
-
-
+
+
))}
@@ -126,7 +130,7 @@ export default function OverviewPage() {